diff --git a/.dev-loop/INGEST_REPORT.md b/.dev-loop/INGEST_REPORT.md index c375750..57cca5a 100644 --- a/.dev-loop/INGEST_REPORT.md +++ b/.dev-loop/INGEST_REPORT.md @@ -1,95 +1,220 @@ -# Consolidated review — knowledge PRs #6–#13 +# Knowledge flush — 9 insight(s) -Eight fork PRs (`dch0202-rsquare`, 2026-07-28 → 2026-08-02) were reviewed together -against `AGENTS.md`. Each PR was audited by an independent reviewer (format rules, -sources, vague-qualifier ban, ≤120 body lines, index/log invariants), then -cross-compared to catch duplication the per-PR flushes could not see — they branched -independently off the same main and rewrote the same shared index/log files. Fork -branches can't be edited from here and several PRs needed content changes (drop a -duplicate, merge a colliding page), so this branch carries the reconciled end-state -rather than merging each PR as-is (which would import the duplicates). +Drained `~/.dev-loop/queue/` (5 session files, 9 pending rows) on 2026-08-05. +Result: **9 new pages**, 1 new category (`infrastructure/agent-orchestration`), +7 existing pages updated with reciprocal links, 1 candidate claim **refuted by +measurement** and corrected before ingest. ## Verified best-practice -Sources are per-page and were live-verified in each originating PR's flush; the -independent re-reviews re-checked them. Landed pages and their evidence base: - -| Page | Confidence | Source basis | -|------|-----------|--------------| -| backend/common/llm/completion-response-validation | verified | OpenAI reasoning guide + chat `object` spec (5 `finish_reason` values), vLLM/LiteLLM reasoning fields; field incident (200/`length`/empty content/8,173-char reasoning) | -| backend/common/llm/context-window-budget | verified | Claude context-window docs, LiteLLM exception mapping, vLLM/Claude Code env-var docs | -| backend/common/integrations/externally-owned-defaults | verified | OpenAI deprecations (notice windows) + models `list`, LiteLLM model_discovery; field incident (alias removed between PR verify and review → 400) | -| backend/common/storage/object-key-persistence | verified | AWS S3 CompleteMultipartUpload + managed-upload API/source, aws-sdk-js issues #1158/#5656 | -| infrastructure/containers/host-cgroup-visibility | field-tested | cgroup_namespaces(7), Docker `--cgroupns=host`, nsenter, k8s #103363; OrbStack repro | -| infrastructure/observability/missing-container-metrics | verified/field-tested | k8s resource-metrics-pipeline docs, kube-prometheus-stack values, kubernetes-mixin; OrbStack #2217 repro | -| platforms/environment/unicode-text-matching | verified | UAX #15, Unicode core §3.12, APFS FAQ, POSIX grep; local repro (macOS 15/APFS, grep 2.6.0-FreeBSD, Python 3.13) | -| platforms/shells/command-text-inspected-before-execution | verified | Claude Code hooks docs, POSIX shell §2.6; local reproduction | -| platforms/processes/non-interactive-cli-invocation | verified | GNU nohup, OpenBSD ssh/ssh_config, git, timeout man pages; no-request-in-gateway-log field incident | -| qa/document-verification/spec-document-gates | field-tested | ESLint, Google mutation testing, RFC 2119, Vale, markdownlint; 32/32 mutant / 62/62 intact RFC sessions | -| qa/document-verification/editing-a-gated-document | field-tested | pgrep, Vale, markdownlint; in-house editing methodology | -| testing/quality/checks-that-cannot-pass | verified | James Shore AoAD2, POSIX grep exit status, Semgrep rule-testing, pytest exit codes; BSD/ugrep measurement | -| testing/quality/spec-artifact-checks | verified | JSON Schema, ESLint RuleTester, pitest, GFM table spec; local cell-count repro + GitHub renderer cross-check | -| testing/quality/harness-reverse-controls | verified | mutation-testing + CI-control sources; field repro (re-fetched all cited URLs, PASS) | - -Three pages were reconciled from two overlapping PR versions each, keeping the more -complete/better-sourced body and folding in the other's unique cases: -- **completion-response-validation** — #12 body (all five `finish_reason` values, - `tool_calls`/`function_call` carve-out, streaming, Responses API, "reasoning is - scratch, not deliverable") kept in `llm/` (coherent with #6/#13); folded in #6's - DeepSeek first-party edge + the field incident. -- **externally-owned-defaults** — #12 generalized body (any repo-external resource) - in `integrations/`; folded in #6's alias-removed field incident + the - gateway-config-vs-live-upstream nuance. -- **non-interactive-cli-invocation** — #12 body (GNU-nohup extension precision, - ssh -n stdin-detach vs BatchMode, pre-log DNS/TLS/proxy + `curl -v`) kept; folded - in #11's DEBIAN_FRONTEND, pager/color TTY case, wrapper-CLI case, field incident. +Every claim was re-tested rather than taken from the session that emitted it. +Six were reproduced locally on this machine; three rest on cited primary sources +plus a field incident. + +### 1. A wrapped tool reports warnings on stderr and exits 0 → `verified` +**Claim:** a gate keyed on exit status misses warnings; capture stderr with +`OUT=$(tool "$f" 2>&1 >/dev/null)` and branch on emptiness — redirection order +decides which stream you get. +**Checked:** POSIX XCU 2.7 ("If more than one redirection operator is specified +with a command, the order of evaluation is from beginning to end"); +[GCC warning options](https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html); +[Claude Code hooks](https://code.claude.com/docs/en/hooks) — `PostToolUse` exit +code 2 "Shows stderr to Claude; the tool already ran". +**Reproduced 2026-08-05** (Apple clang, macOS): unused-variable source → exit 0 +with `warning:` on stderr; clean source → exit 0, empty stderr; undeclared +identifier → exit 1. Same run: `2>&1 >/dev/null` captured `STDERR-DIAG`, +`2>/dev/null >&1` captured `STDOUT-PAYLOAD` — the reversed form feeds the build +artifact back as if it were a diagnostic. + +### 2. A `capture-pane` diff is not delivery evidence → `verified` +**Claim:** check the target TUI's busy/queued indicator first; the pane changes +for a queued keystroke because the tty echoes it. +**Checked:** [termios(3)](https://man7.org/linux/man-pages/man3/termios.3.html) +`ECHO` — "Echo input characters", independent of when the program calls +`read()`; [tmux(1)](https://man7.org/linux/man-pages/man1/tmux.1.html). +**Reproduced 2026-08-05** (tmux 3.7b, macOS): sent `echo SECOND_PROMPT_MARKER` +to a pane running `sleep 6`. Pane diff = **YES**, marker present once as echoed +text, command's own output line count = **0**. After the sleep drained, the +command ran and the output line appeared. A pane-diff test placed first reports +"delivered" for exactly the queued case it was written to detect. + +### 3. `--` before an interpolated operand → `verified` +**Checked:** POSIX XBD 12.2 Guideline 10 — "The first `--` argument that is not +an option-argument should be accepted as a delimiter indicating the end of +options. Any following arguments should be treated as operands, even if they +begin with the '-' character." +**Reproduced 2026-08-05** (tmux 3.7b): `tmux send-keys -t S -l "-n hello"` → +`command send-keys: unknown flag -n`, exit 1; with `-- "-n hello"` → exit 0. +Generalized past tmux: this is option parsing in the callee, not shell quoting. + +### 4. A Stop-gate's terminal set must include instructed pauses → `verified` +**Checked:** [Claude Code hooks](https://code.claude.com/docs/en/hooks) — +`stop_hook_active` is a documented Stop-hook input; hooks exit early when it is +true, and Claude Code overrides a Stop hook after eight consecutive blocks +(`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`). +**Reproduced in-repo at `95cf947`:** `hooks/loop-gate.sh:55` lists +`done|approved|merged|failed|""` as terminal, while +`skills/orchestrate/templates/session-prompt.md:20` instructs a plan-phase +worker to record `plan_ready` and "wait for an approval message. Do NOT write +implementation code yet." `scripts/status-update.sh:6` confirms `plan_ready` is +a first-class phase. The gate therefore fires on a worker that obeyed its own +prompt; `loop-gate.sh:30`'s `stop_hook_active` return is what bounds it. + +### 5. Worktree-isolated worker briefs → `verified`, **with the candidate's stated mechanism refuted** +**Candidate claimed:** the `worktree_escape` guardrail blocks *reads* (`ls`, +`cat`) of the main checkout as well as writes. +**Measured 2026-08-05** against groundwork guardrails 1.0.0 +`hooks/bash-guard.sh` (built a real repo + linked worktree and ran the hook): + +| Command from a linked worktree | Decision | +|--------------------------------|----------| +| `cat /f` | allow | +| `ls /.orchestration` | allow | +| `grep -n x /f` | allow | +| `cp ./a /b` | **fires** | +| `echo z > /f` | **fires** | + +The rule (`bash-guard.sh:217-250`) matches an absolute main-root mention +together with a write verb (`rm|mv|cp|tee|mkdir|touch|install|dd`) or a redirect +to an absolute path. Reads pass. **The directive survives** (worktree-relative +output paths; orchestrator collects) — the reason given for it did not, so the +page documents the verified write-only asymmetry and adds reads as the +*sanctioned* way to consume shared input. Logged as `contradiction` in `log.md`. + +### 6. httpx repeated form fields → `verified` (source-level) +**Checked:** [`httpx/_content.py`](https://github.com/encode/httpx/blob/master/httpx/_content.py). +`encode_request`: `if data is not None and not isinstance(data, Mapping): +warnings.warn("Use 'content=<...>' to upload raw bytes/text content.", +DeprecationWarning); return encode_content(data)` — a list of tuples is sent as +**raw body**, with no `application/x-www-form-urlencoded` header, so the server +parses an empty form and still returns its success status. +`encode_urlencoded_data` expands a `list`/`tuple` **value** into repeated +`(key, item)` pairs — confirming `data={"k": ["a","b"]}` is the correct form. +Also [httpx quickstart](https://www.python-httpx.org/quickstart/). httpx is not +installed on this machine, so this one is source-verified rather than re-run. + +### 7. Client-side throttle bypassed by auth refresh → `field-tested` +**Checked:** the candidate asserted flatly that "token requests count toward the +rate limit". That is **provider-specific** — +[Okta](https://developer.okta.com/docs/reference/rl2-token-oauth/) and +[GitHub](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) +document separate budgets for token/OAuth endpoints. The directive was therefore +narrowed: put the throttle where every outbound request passes, and read the +provider's docs for which bucket the token endpoint is in (with a documented +default when it is unstated). An Auth0 citation was dropped after fetching the +page and finding it does not support the claim. +**Field evidence:** `auto-trading-bot` commit `82a077e` +(`src/broker/kis_client.py`) — `_headers()` ran `_throttle()` then called +`_get_token()`, so on newly-issued-token days the token POST and the API GET +landed in the same second and the provider rejected the call; logs show +`POST …:00.354` → issued `…:00.495` → rejected `…:00.543`, and cached-token days +passed on identical code. Confidence held at `field-tested` — the mechanism is +production-observed, not doc-derived. + +### 8. Enumerate call sites by callee, not by parameter name → `field-tested` +**Checked:** [Python tutorial 4.9.2](https://docs.python.org/3/tutorial/controlflow.html) +— a function may be called positionally or with `kwarg=value`, which is exactly +why a keyword-name grep cannot see positional callers; +[Fowler, test impact analysis](https://martinfowler.com/articles/rise-test-impact-analysis.html) +for deriving reach from the change. `refactoring.com`'s Change Function +Declaration entry was fetched but returned no body text, so it is **not** cited. +**Field evidence:** a migration scoped by `grep -rn "repo_rows"` returned 13 +hits, all keyword-style, and was reported "7 of 13"; the full suite then gave +`Ran 472 tests / FAILED (failures=11)`, all in one file passing the value as the +callee's 4th positional argument, plus a `rows_for()` helper still supplying the +removed shape to 5 more call sites. + +### 9. `${VAR:-default}` swallows an empty value → `verified` +**Checked:** POSIX XCU 2.6.2 — "use of the in the format shall result in +a test for a parameter that is unset or null; omission of the shall +result in a test for a parameter that is only unset." +**Reproduced 2026-08-05:** with `V=""`, `${V:-def}` → `def`, `${V-def}` → empty; +with `V` unset, both → `def`. Origin case (`WATCH_TMUX=` failing to disable a +liveness check reading `${WATCH_TMUX:-tmux}`, and `WATCH_TMUX=/nonexistent…` +working) is consistent with the spec. ## Existing-layer check -Cross-PR and against-main duplication was the focus. Findings and resolutions: - -- **spec-artifact-checks (#8) ≡ document-conformance-checks (#9)** — same case - (coverage-vs-validity split, per-check negative controls, GFM pipe parsing, - ESLint/Semgrep/mutation examples). #9's report predated awareness of #8. → - **#8 kept canonical; #9's page dropped, `testing/docs-as-spec` category not created.** -- **completion-response-validation (#6) ≈ llm-response-completeness (#12)** — ~95% - same case (HTTP 200 ≠ usable output; `length`/blank/reasoning-budget). → - **merged into one `llm/` page; #12's `integrations/` copy dropped.** -- **gateway-model-alias-defaults (#6) ≈ externally-owned-defaults (#12)** — ~80%; - #12 generalizes the model-alias case to any external resource. → - **kept the general `integrations/` page; #6's LLM-only page dropped.** -- **non-interactive-cli-invocation** — created by BOTH #11 and #12 (file collision). - → **single reconciled page.** -- Distinct (no overlap, all landed): checks-that-cannot-pass, harness-reverse-controls, - spec-document-gates, editing-a-gated-document, unicode-text-matching, - command-text-inspected-before-execution, object-key-persistence, context-window-budget, - host-cgroup-visibility, missing-container-metrics. -- Reciprocal `related:` links added on existing pages (tests-that-cannot-fail, - timeouts-and-retries, environment-config, release-gates, background-services, - portable-shell-scripts, timezone-and-locale, paths-case-and-line-endings, - acceptance-criteria, resource-limits-and-probes, logs-metrics-signals, - minimum-case-set). A dropped-page backlink (#6 → gateway-model-alias-defaults on - environment-config and release-gates) was retargeted to externally-owned-defaults. -- Invariants verified programmatically: all `related:`/inline `[id]` references - resolve, every page listed in its domain index, no duplicate ids, no page >120 - body lines. +**Pages read in full before writing anything:** `INDEX.md`, `AGENTS.md`, +`templates/page.md`, the domain indexes for infrastructure / testing / platforms +/ backend / debugging / qa, and — as the overlap candidates — +`platforms/shells/portable-shell-scripts`, +`platforms/processes/non-interactive-cli-invocation`, +`testing/quality/tests-that-cannot-fail`, `testing/quality/checks-that-cannot-pass`, +`backend/common/reliability/timeouts-and-retries`, +`infrastructure/config/environment-config`, `qa/process/regression-scope`, +`backend/python/index.md`. + +**Merge-vs-create outcomes:** + +| Candidate | Nearest existing page | Decision | +|-----------|----------------------|----------| +| stderr/exit-0 gate | `testing/quality/checks-that-cannot-pass` | **Create.** That page's trigger is a check whose *target does not exist yet*; this is a check whose *tool succeeded*. Cross-linked both ways; the new page defers to it for the known-good-input discipline | +| `--` separator | `platforms/shells/portable-shell-scripts` §5 "build argument lists safely" | **Create.** That page's "When this applies" is cross-machine/cross-shell portability; this failure happens on one machine in one shell. Its §5 is about shell word-splitting, this is callee option parsing — the new page says so explicitly | +| `${VAR:-}` vs `${VAR-}` | `portable-shell-scripts` (edge case `"${OPT:-}"`), `infrastructure/config/environment-config` §5 | **Create.** Same reason — different trigger. `environment-config` owns service config schemas and required-keys-get-no-default; this owns a caller trying to switch a script off. Linked both ways | +| httpx repeated form fields | `testing/quality/tests-that-cannot-fail` | **Create + merge.** Trigger matches ("a bug shipped through an area the suite reported as covered"), but the encoding mechanics do not belong in a general page. Added **one new never-fails row** to `tests-that-cannot-fail` — "HTTP test of a write endpoint asserting only the response status" — pointing at the new page | +| throttle/token | `backend/common/reliability/timeouts-and-retries` | **Create.** That page owns timeouts, retry-by-failure-type and 429 *reaction*; this owns *proactive* client-side pacing and where the throttle must sit. Linked both ways | +| call-site enumeration | `qa/process/regression-scope` | **Create.** `regression-scope` already has the adjacent edge case "code with no test coverage and unclear callers → trace callers before scoping"; the new page is the how. Linked both ways | +| 3 agent-orchestration pages | none | **Create.** No page in any domain covers driving/gating/isolating agent worker sessions. `platforms/processes/non-interactive-cli-invocation` is the nearest neighbour (invoking a prompt-capable CLI unattended) and is now linked from the pane page | + +**Conflicts flagged:** one — the `worktree_escape` reads-vs-writes claim (§5 +above), logged in `log.md` as a `contradiction` entry rather than silently +written as fact. + +**Reciprocal links added:** `portable-shell-scripts` → all three new shells +pages; `checks-that-cannot-pass` → `exit-status-vs-diagnostics`; +`tests-that-cannot-fail` → `write-path-assertions`; `regression-scope` → +`call-site-enumeration`; `timeouts-and-retries` → `client-side-rate-limiting`; +`environment-config` → `env-var-off-switches`; +`non-interactive-cli-invocation` → `pane-delivery-confirmation`. ## Routing decision -- `backend/common/llm/` (new) — LLM-specific server concerns: completion-response-validation, - context-window-budget. Coherent home shared by #6 and #13. -- `backend/common/integrations/` (new) — general repo-external-dependency concern: - externally-owned-defaults. Kept separate from `llm/` because its scope is any - external resource (bucket/queue/index), not LLM-only. -- `backend/common/storage/` (new) — object-key-persistence. -- `qa/document-verification/` (new) — spec-document-gates, editing-a-gated-document. - Introduced by both #10 and #11; unified into one index section. -- `testing/quality/` (existing) — checks-that-cannot-pass, spec-artifact-checks, - harness-reverse-controls (test/check-authoring discipline, distinct from - qa/document-verification which is release-process gate design). -- `platforms/{environment,shells,processes}/` (existing) — unicode-text-matching, - command-text-inspected-before-execution, non-interactive-cli-invocation. -- `infrastructure/{containers,observability}/` (existing) — host-cgroup-visibility, - missing-container-metrics. - -Source PRs #6–#13 are closed with a disposition comment crediting the author. +| # | Insight | Target | +|---|---------|--------| +| 1 | Warnings on stderr with exit 0 | `platforms/shells/exit-status-vs-diagnostics.md` | +| 2 | Pane diff ≠ delivery | `infrastructure/agent-orchestration/pane-delivery-confirmation.md` | +| 3 | `--` before interpolated operands | `platforms/shells/option-like-argument-values.md` | +| 4 | Stop-gate terminal set | `infrastructure/agent-orchestration/session-completion-gates.md` | +| 5 | Worktree-relative worker briefs | `infrastructure/agent-orchestration/worktree-isolated-workers.md` | +| 6 | Write-path assertions / httpx form encoding | `testing/quality/write-path-assertions.md` | +| 7 | Throttle vs auth refresh | `backend/common/reliability/client-side-rate-limiting.md` | +| 8 | Enumerating call sites | `qa/process/call-site-enumeration.md` | +| 9 | Env-var off switches | `platforms/shells/env-var-off-switches.md` | + +**New category — `infrastructure/agent-orchestration` (3 pages).** Justified +because no existing category covers it: `ci-cd` is pipeline structure, +`config`/`deploy`/`observability` are service lifecycle, `containers` is images +and limits, and `platforms/processes` owns *invoking* a CLI unattended, not +*coordinating a fleet of worker sessions*. All three insights hinted +`infrastructure`, and the domain's route line + `INDEX.md` were updated so the +category is reachable rather than orphaned. Three pages seed it at once, so it +does not land as a one-page category. + +**Routing calls worth a reviewer's attention:** + +- **#8 → `qa`, not `testing` or `backend`.** The queue hinted `testing`, but the + trigger is a signature migration, not writing tests. `qa` owns regression + scoping and the Integration ring ("the contract changed — then test **every** + consumer of that contract, not a sample"); a function signature is a contract + and its call sites are the consumers. `backend/common` was rejected because + its categories are all runtime concerns and the lesson is language-agnostic + code-change methodology. Alternative home if you disagree: a new + `testing/quality` page — say so and it moves. +- **#6 → `testing/quality`, not `backend/python`.** The artifact is test code; + `backend/python` is server-side application code. The httpx mechanics ride + along as the concrete cause. +- **#1 and #3 → `platforms/shells` rather than the new orchestration category.** + Both mechanisms (redirection order, POSIX option parsing) are reusable well + beyond agent harnesses, and both were reproduced with non-agent tools. +- **Three new pages in one category (`platforms/shells`).** Each has a distinct + trigger per the one-case-per-page rule; none is a variant of another. + +## Verification of the wiki's own invariants + +Ran a mechanical check over all 148 pages after the edits: every `related:` id +and inline `[page-id]` reference resolves, no page exceeds 120 body lines, all +nine new pages appear in their domain index with a "load when" line, all carry +`When this applies` / `Do this` / `Sources`, and the banned vague qualifiers are +absent (three initial hits were rewritten). `log.md` has `ingest`, `revise`, and +`contradiction` entries for this flush. diff --git a/INDEX.md b/INDEX.md index d6d4238..d0a2b7f 100644 --- a/INDEX.md +++ b/INDEX.md @@ -12,9 +12,9 @@ follow the cross-pointers in their index or take the next matching seeded domain | [databases](wiki/databases/index.md) | **seeded** | Designing schemas/tables/keys, choosing or evaluating indexes, writing or optimizing queries, choosing transaction/isolation behavior | | [backend](wiki/backend/index.md) | **seeded** | Server-side application code — language-agnostic (`common/`: API contracts, idempotency, JWT, timeouts/retries, caching, jobs, transactions in app code, shared state/pools, errors, LLM completion validation & context budgeting, consuming external-API responses, externally-owned defaults, object-storage references) plus stack subtrees: `java/` (JPA, Spring proxies, JVM threads/memory), `node/` (event loop, promises, runtime validation, shutdown), `python/` (GIL/asyncio, pydantic, WSGI/ASGI workers, language traps) | | [frontend](wiki/frontend/index.md) | **seeded** | Web UI code: state placement, rendering performance, in-UI data fetching (races, infinite scroll), auth token handling, forms, XSS-safe output, accessibility | -| [infrastructure](wiki/infrastructure/index.md) | **seeded** | CI/CD pipelines, secrets in build/deploy, container image builds, rollout/rollback strategy, observability (logs/metrics/alerting) | +| [infrastructure](wiki/infrastructure/index.md) | **seeded** | CI/CD pipelines, secrets in build/deploy, container image builds, rollout/rollback strategy, observability (logs/metrics/alerting), orchestrating parallel agent worker sessions (pane delivery, completion gates, worktree-isolated briefs) | | [testing](wiki/testing/index.md) | **seeded** | Writing or structuring automated tests: level choice, cases/assertions, test data, mock decisions, flaky tests (release-process quality → qa) | -| [qa](wiki/qa/index.md) | **seeded** | Release-quality process: release gates, regression scoping, bug reports, severity/priority triage, exploratory testing, automated verification of document deliverables (spec/RFC gates) (writing automated test code → testing) | +| [qa](wiki/qa/index.md) | **seeded** | Release-quality process: release gates, regression scoping, call-site/consumer enumeration for a signature or contract change, bug reports, severity/priority triage, exploratory testing, automated verification of document deliverables (spec/RFC gates) (writing automated test code → testing) | | [debugging](wiki/debugging/index.md) | **seeded** | Diagnosing a failure — finding what is wrong and why: reproducing, bisection, hypothesis testing, traces/logs, intermittent failures (fixing the diagnosed fault → its owning domain) | | [security](wiki/security/index.md) | **seeded** | Trust-boundary decisions: input validation, session-vs-token auth choice, per-resource authorization (IDOR), secrets hygiene, dependency trust, PII handling (XSS rendering → frontend; CI secrets → infrastructure; JWT implementation → backend/frontend auth) | | [platforms](wiki/platforms/index.md) | **seeded** | OS-level differences breaking code across macOS/Linux/Windows: shell portability, BSD-vs-GNU CLI, filesystem case/line endings, Unicode normalization in text/file-name matching, commands inspected before execution, background services/cron, invoking prompt-capable CLIs non-interactively, toolchain version pinning | diff --git a/log.md b/log.md index 1c6293b..7eb8bef 100644 --- a/log.md +++ b/log.md @@ -37,3 +37,6 @@ Append-only. Format: `## [YYYY-MM-DD] &1 >/dev/null`, order matters; clang repro), option-like-argument-values (`--` before any interpolated operand; POSIX Guideline 10 + tmux repro), env-var-off-switches (`${VAR:-d}` swallows an empty value — use `${VAR-d}` or a sentinel; POSIX 2.6.2). backend/common/reliability +1: client-side-rate-limiting (auth refresh bypasses a per-method throttle; kis_client.py 82a077e). testing/quality +1: write-path-assertions (assert the persisted row, not the status; httpx form-encodes only a Mapping — list-of-tuples is sent as raw content, verified in httpx/_content.py). qa/process +1: call-site-enumeration (search by callee, not by parameter name — positional callers and test helpers hide from a keyword grep). +## [2026-08-05] revise | Reciprocal related-links + one merge row for the 2026-08-05 ingest: portable-shell-scripts → the 3 new shells pages; checks-that-cannot-pass → exit-status-vs-diagnostics; tests-that-cannot-fail → write-path-assertions (plus a new never-fails row: "HTTP test of a write endpoint asserting only the response status"); regression-scope → call-site-enumeration; timeouts-and-retries → client-side-rate-limiting; environment-config → env-var-off-switches; non-interactive-cli-invocation → pane-delivery-confirmation. +## [2026-08-05] contradiction | A queued insight asserted the groundwork `worktree_escape` guardrail blocks READS (`ls`, `cat`) out of the main checkout as well as writes. Refuted by direct measurement against guardrails 1.0.0 `hooks/bash-guard.sh`: the rule fires only on a write verb (rm|mv|cp|tee|mkdir|touch|install|dd) or a redirect to an absolute path — `cat`, `ls`, and `grep` on main-checkout paths from a linked worktree all pass. infrastructure/agent-orchestration/worktree-isolated-workers.md documents the verified write-only behavior; the directive (worktree-relative output paths) survives, its stated mechanism did not. diff --git a/wiki/backend/common/reliability/client-side-rate-limiting.md b/wiki/backend/common/reliability/client-side-rate-limiting.md new file mode 100644 index 0000000..801d556 --- /dev/null +++ b/wiki/backend/common/reliability/client-side-rate-limiting.md @@ -0,0 +1,85 @@ +--- +id: backend-common-reliability-client-side-rate-limiting +domain: backend +category: reliability +applies_to: [general] +confidence: field-tested +sources: + - https://developer.okta.com/docs/reference/rl2-token-oauth/ + - https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +last_verified: 2026-08-05 +related: [backend-common-reliability-timeouts-and-retries, backend-common-auth-jwt-server-side, debugging-concurrency-intermittent-failures] +--- + +# A Client-Side Throttle That the Auth Refresh Slips Past + +## When this applies + +Your API client wrapper enforces a provider's requests-per-second cap itself +(a minimum interval or token bucket around each call), and a rate-limit error +still comes back — characteristically on the **first** call of a process, or on +some days and not others. + +## Do this + +1. **Put the throttle where every outbound request passes, including the ones + the client issues on its own behalf.** Token/credential refresh is issued from + inside a header builder or an interceptor *below* the wrapper method, so a + throttle applied per public method never sees it. Either wrap the transport + (session/adapter/middleware) or call the throttle explicitly from the refresh + path as well. +2. **Stamp the throttle's timestamp immediately before the request goes out**, + inside the same function that issues it. A timestamp written by the caller + before an inner request happens leaves that inner request unaccounted for and + lets the next one land in the same second. +3. **Make the refresh and the call it enables two separate slots.** After a + refresh has consumed a slot, the request that needed the token waits its own + interval: + + ```python + def _headers(self, tr_id): + token = self._get_token() # throttles internally when it must refresh + self._throttle() # separates this call from that refresh + return {...} + ``` + +4. **Read the provider's docs for which bucket the token endpoint is in** — this + differs by provider and decides step 1's shape: + +| Provider's rule | Client design | +|-----------------|---------------| +| Token endpoint shares the general request budget | One throttle covering every request, refresh included | +| Token endpoint has its own separate budget | Two counters; the refresh must not consume the API budget's slot, and must still respect its own | +| Undocumented | Route the refresh through the shared throttle — one extra interval per refresh costs a fraction of a second and a shared bucket costs a failed call | + +5. **Check the process's initial state.** A last-request timestamp initialized to + zero must produce "no wait" against a monotonic clock and "no wait" only for + the genuinely first request — assert it in a test that issues two calls back + to back from a fresh client and requires the measured gap. +6. **Keep the server's own 429 handling as well** — the client throttle prevents + the common case, the retry path ([backend-common-reliability-timeouts-and-retries]) + handles the rest. + +## Edge cases + +| Case | Then | +|------|------| +| The failure reproduces only on days the cached token expired | That is the signature of this bug, not intermittency — compare a failing day's log timestamps against a day the cache was warm | +| Several client instances run in one process | The throttle state must be shared across them (class/module-level or an injected limiter); per-instance state multiplies the effective rate by the instance count | +| Several processes or hosts call the same account | Per-process throttling cannot hold an account-wide cap — move the limiter to a shared store ([backend-common-concurrency-distributed-locks] for the coordination primitive) or divide the budget explicitly per process | +| The refresh is triggered lazily by a 401 retry rather than by expiry | The retry path issues a token request too — route it through the same throttle, or the retry storms the limit it was recovering from | +| The provider counts by endpoint class, not per account | Model the buckets the provider documents; one global interval under-uses the fast bucket and still overruns the slow one | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| Apply the throttle in each public wrapper method | Apply it at the transport, or call it from the refresh path too | Auth refresh is issued from below the wrapper and is invisible to a per-method throttle | +| Raise the minimum interval until the errors stop | Find which request was unaccounted for and route it through the throttle | A larger interval slows every call to hide one uncounted request, and still fails when two uncounted ones coincide | +| File the first-call failure as intermittent and add a retry | Compare a fresh-token run against a cached-token run | The trigger is the token cache state, which is deterministic — a retry hides a reproducible ordering bug | + +## Sources + +- https://developer.okta.com/docs/reference/rl2-token-oauth/ — OAuth token endpoints carry their own documented rate limits, separate from general API limits; which bucket applies is provider-specific +- https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api — access-token requests are budgeted separately from REST API requests +- Field reproduction, `auto-trading-bot` commit `82a077e` (`src/broker/kis_client.py`): the wrapper's `_throttle()` ran in `_headers()`, which then called `_get_token()`; on days the token was newly issued the token POST and the following API GET landed in the same second and the provider rejected the call for exceeding its per-second cap. Logs from two such days show `POST …:00.354` → token issued `…:00.495` → balance call rejected `…:00.543`; on cached-token days the same code succeeded. The fix throttles inside `_get_token()` and throttles again after it in `_headers()` diff --git a/wiki/backend/common/reliability/timeouts-and-retries.md b/wiki/backend/common/reliability/timeouts-and-retries.md index df130d6..6a29ee1 100644 --- a/wiki/backend/common/reliability/timeouts-and-retries.md +++ b/wiki/backend/common/reliability/timeouts-and-retries.md @@ -9,7 +9,7 @@ sources: - https://sre.google/sre-book/addressing-cascading-failures/ - https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ last_verified: 2026-07-10 -related: [backend-common-api-design-idempotency, backend-common-llm-completion-response-validation] +related: [backend-common-api-design-idempotency, backend-common-llm-completion-response-validation, backend-common-reliability-client-side-rate-limiting] --- # Calling Another Service over the Network: Timeouts, Retries, Backoff diff --git a/wiki/backend/index.md b/wiki/backend/index.md index 9e683bd..198eccc 100644 --- a/wiki/backend/index.md +++ b/wiki/backend/index.md @@ -31,6 +31,7 @@ Match your situation to a "load when" line; load only matching pages. | Page | Load when | |------|-----------| | [timeouts-and-retries](common/reliability/timeouts-and-retries.md) | Your service calls another service/external API/DB over the network — setting timeouts and deadlines, deciding what to retry per failure type, backoff/jitter, capping concurrency against a slow dependency; debugging pool exhaustion or retry storms | +| [client-side-rate-limiting](common/reliability/client-side-rate-limiting.md) | Your API client enforces a provider's requests-per-second cap itself and still hits the limit — on the first call of a process, or on some days only; deciding where the throttle lives so token/credential refresh cannot bypass it, whether the token endpoint shares the request budget, and how the limit holds across instances/processes | ### caching diff --git a/wiki/infrastructure/agent-orchestration/pane-delivery-confirmation.md b/wiki/infrastructure/agent-orchestration/pane-delivery-confirmation.md new file mode 100644 index 0000000..077bf05 --- /dev/null +++ b/wiki/infrastructure/agent-orchestration/pane-delivery-confirmation.md @@ -0,0 +1,76 @@ +--- +id: infrastructure-agent-orchestration-pane-delivery-confirmation +domain: infrastructure +category: agent-orchestration +applies_to: [tmux, general] +confidence: verified +sources: + - https://man7.org/linux/man-pages/man3/termios.3.html + - https://man7.org/linux/man-pages/man1/tmux.1.html +last_verified: 2026-08-05 +related: [platforms-shells-option-like-argument-values, infrastructure-agent-orchestration-session-completion-gates, platforms-processes-non-interactive-cli-invocation] +--- + +# Confirming a Keystroke Sent to a Terminal Pane Was Actually Consumed + +## When this applies + +An orchestrator drives another program through a terminal multiplexer — sending +a prompt to an agent CLI or a command to a shell with `tmux send-keys`, then +reading the pane back with `capture-pane` to decide whether to proceed, resend, +or escalate. + +## Do this + +1. **Read the target program's own busy/queued indicator first**, in the last + few non-empty pane lines, and treat its presence as "not consumed yet": + + ```sh + tail_lines=$(tmux capture-pane -p -t "$s" | grep -v '^$' | tail -6) + printf '%s' "$tail_lines" | grep -q "$BUSY_MARKER" && return 1 # still queued + ``` + +2. **Accept a pane diff as delivery evidence only when that indicator is + absent.** The terminal line discipline echoes typed characters as they + arrive, independent of whether the foreground program has read them, so the + pane changes for a queued keystroke exactly as it does for a consumed one. +3. **Prefer an effect the target produces to text the terminal echoed.** Order + the checks by how much they prove: + +| Evidence | Proves | Use as | +|----------|--------|--------| +| A file, status entry, or IPC message the target writes on receipt | The program ran the input | The confirmation | +| The target's own idle/ready prompt returning after the send | The program consumed and finished the input | A confirmation when no artifact exists | +| The target's busy/queued indicator present | The input is buffered, not consumed | A retry-later signal | +| Pane content differs from before the send | Bytes reached the tty | Nothing on its own | + +4. **Bound the wait and escalate on the indicator, not on the diff.** When the + busy marker is still present after the deadline, report "target busy" — a + distinct outcome from "send failed", which the `send-keys` exit status owns + ([platforms-shells-option-like-argument-values]). +5. **Capture the pane before and after with the same command and flags**, so a + redraw, resize, or scroll-region change is not read as new content. + +## Edge cases + +| Case | Then | +|------|------| +| The target program disables echo (password prompt, raw-mode TUI) | The pane does **not** change on send; absence of a diff is not evidence of failure either — fall back to the artifact or ready-prompt check | +| The target repaints and the echoed text scrolls away | Search a fixed window of the last N non-empty lines, not the whole scrollback; a repaint drops the marker count to 0 while the input is still queued | +| The send is a multi-line prompt | Send the body and the submit key as separate calls and check the indicator between them; a single blob can be consumed partially | +| Several sends are in flight to one pane | Serialize them — one outstanding send per pane, confirmed before the next; interleaved input is reordered by the tty buffer, not by your script | +| No busy indicator exists in the target | Require the artifact check from the table; without either, the harness cannot distinguish queued from consumed | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| Diff `capture-pane` before/after and call a difference "delivered" | Check the busy/queued indicator first and use the diff only when it is absent | The tty echoes typed characters while the program is busy, so the diff reports success for the queued case the check exists to catch | +| Sleep a fixed interval after `send-keys` and continue | Poll the indicator (or the artifact) until it clears, with a deadline | The right interval is the target's work time, which is what you are trying to measure | +| Resend on the first unchanged capture | Distinguish "busy" from "not delivered" before resending | Resending into a busy pane queues a duplicate that runs when the pane drains | + +## Sources + +- https://man7.org/linux/man-pages/man3/termios.3.html — `ECHO` in `c_lflag`: "Echo input characters." The terminal driver echoes independently of when the program calls `read()` +- https://man7.org/linux/man-pages/man1/tmux.1.html — `send-keys` writes keys into a pane's input; `capture-pane` copies the pane's visible contents — neither reports whether the foreground process consumed the input +- Field reproduction 2026-08-05 (tmux 3.7b, macOS): a pane running `sleep 6` received `echo SECOND_PROMPT_MARKER`. Pane content changed (diff = YES) and the marker appeared once as echoed text, while the command's own output line count stayed 0; after the sleep drained, the command ran and the output line appeared diff --git a/wiki/infrastructure/agent-orchestration/session-completion-gates.md b/wiki/infrastructure/agent-orchestration/session-completion-gates.md new file mode 100644 index 0000000..45cb08f --- /dev/null +++ b/wiki/infrastructure/agent-orchestration/session-completion-gates.md @@ -0,0 +1,73 @@ +--- +id: infrastructure-agent-orchestration-session-completion-gates +domain: infrastructure +category: agent-orchestration +applies_to: [claude-code, general] +confidence: verified +sources: + - https://code.claude.com/docs/en/hooks +last_verified: 2026-08-05 +related: [infrastructure-agent-orchestration-pane-delivery-confirmation, infrastructure-agent-orchestration-worktree-isolated-workers, platforms-shells-exit-status-vs-diagnostics] +--- + +# A Gate That Blocks a Worker Session from Ending Mid-Workflow + +## When this applies + +You are writing a completion gate — a `Stop`/`SubagentStop` hook or equivalent — +that refuses to let an orchestrated worker session end while its recorded phase +says the work is unfinished. Also when such a gate fires on a worker that did +exactly what its own prompt told it to do. + +## Do this + +1. **Enumerate every phase at which the protocol itself tells a worker to + stop**, and put all of them in the gate's terminal set — not only the phases + that mean "finished". Read the session prompt and the phase vocabulary side + by side and classify each phase: + +| Phase kind | Example | Gate treats it as | +|------------|---------|-------------------| +| Completed | `done`, `merged`, `failed` | terminal — allow stop | +| Instructed pause awaiting an external actor | `plan_ready` awaiting approval, `impl_done` awaiting review | terminal — allow stop | +| Unknown or unset | `""`, a phase name the gate does not recognize | terminal — allow stop, and log the unrecognized value | +| Work in progress the worker abandoned | `implementing`, `planning` | blocking — emit the instruction and block | + +2. **Derive the set from the prompt that the workers actually receive**, and + re-derive it whenever that prompt or the phase vocabulary changes. The two + are one contract; a phase added to the status script without a matching gate + entry becomes a stall. +3. **Make the gate self-limiting via the harness's re-entry flag.** In Claude + Code, exit 0 immediately when `stop_hook_active` is true, before any other + logic — the flag marks a session already continuing because of this hook, and + without the early return the gate can block indefinitely. Claude Code + overrides a Stop hook after it blocks eight consecutive times. +4. **No-op outside the managed workspace.** Locate the orchestration state by + walking up from the session's `cwd`; when it is absent, exit 0. A gate that + assumes it is managed fires in every unrelated session on the machine. +5. **Say what to do, not that something is wrong.** The block message names the + phase, the next action, and the exact command that records completion — a + blocked session's only input is that text. + +## Edge cases + +| Case | Then | +|------|------| +| A worker legitimately stops at an approval point but its status was never updated | The gate is right to block; make the status update the last step of the instructed pause so "stopped where told" and "recorded as paused" cannot diverge | +| The gate's state file is unreadable or its parser is missing | Exit 0 and log — a gate that blocks on its own malfunction traps every session | +| Several workers share one status directory | Match the entry by the session's resolved physical `cwd`; on macOS resolve `/var`→`/private/var` and symlinks on both sides before comparing | +| A phase means "waiting on another worker" | Terminal — the worker cannot progress it; the orchestrator's wait loop owns that transition | +| The worker cannot reach a terminal phase because the task is genuinely blocked | Provide a `failed` transition it may record itself; without one, the only escapes are fabricated completion or an eight-block override | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| List only "success" phases as terminal | Add every phase at which the protocol instructs a stop, including mid-workflow handoffs | The gate otherwise fights the prompts the system issues, pushing the worker to fabricate completion or to do work it was told to hold | +| Treat an unrecognized phase value as unfinished | Treat it as terminal and log the value | A typo or a newly added phase would otherwise trap sessions until someone reads the hook | +| Rely on the block message alone to stop a loop | Return early on the harness's re-entry flag first | The message does not bound repetition; the flag is what makes the gate fire once | + +## Sources + +- https://code.claude.com/docs/en/hooks — `Stop`/`SubagentStop` input includes `stop_hook_active`; hooks check it and exit early to allow the stop. Claude Code overrides a Stop hook after it blocks eight times in a row without progress (cap adjustable via `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`) +- Field reproduction 2026-08-05, dev-loop repo at `95cf947`: `hooks/loop-gate.sh:55` lists `done|approved|merged|failed|""` as terminal, while `skills/orchestrate/templates/session-prompt.md:20` instructs a plan-phase worker to record `plan_ready` and "wait for an approval message. Do NOT write implementation code yet." A worker that followed its prompt exactly was blocked; the `stop_hook_active` early return at line 30 is what kept the block from repeating diff --git a/wiki/infrastructure/agent-orchestration/worktree-isolated-workers.md b/wiki/infrastructure/agent-orchestration/worktree-isolated-workers.md new file mode 100644 index 0000000..dece78c --- /dev/null +++ b/wiki/infrastructure/agent-orchestration/worktree-isolated-workers.md @@ -0,0 +1,72 @@ +--- +id: infrastructure-agent-orchestration-worktree-isolated-workers +domain: infrastructure +category: agent-orchestration +applies_to: [git, general] +confidence: verified +sources: + - https://git-scm.com/docs/git-worktree +last_verified: 2026-08-05 +related: [infrastructure-agent-orchestration-session-completion-gates, infrastructure-agent-orchestration-pane-delivery-confirmation, platforms-shells-command-text-inspected-before-execution] +--- + +# Writing the Brief for a Worker Confined to Its Own Worktree + +## When this applies + +You are authoring the brief, prompt, or output contract for parallel agent +workers that each run in their own git worktree under a guardrail that stops +writes outside it. Also when workers stall at the same step and the coordinator's +wait loop keeps escalating with no error from the task itself. + +## Do this + +1. **Write every path a worker produces as worktree-relative**, so the + correctness of the brief does not depend on where the worktree lives: + `.orchestration/plans/.md`, not `/repo/.orchestration/plans/.md`. +2. **Collect, don't deposit.** The orchestrator reads each worker's artifacts + out of that worker's worktree after the phase; a worker never writes into the + shared main checkout. This is what keeps N workers from racing on one file. +3. **Route by direction — the guardrail is asymmetric**, so state it precisely + in the brief: + +| Worker action on a main-checkout path | Guardrail outcome | Brief should say | +|----------------------------------------|-------------------|------------------| +| Write (`cp`, `mv`, `mkdir`, `touch`, `tee`, `rm`, `dd`, or a `>`/`>>` redirect to an absolute path) | fires — `ask` or `deny` | Never — emit to a worktree-relative path | +| Read (`cat`, `ls`, `grep` with no redirect) | passes | Allowed, and the right way to consume shared read-only input | + +4. **When workers must share a mutable directory, put it outside both the main + checkout and the worktrees** and pass its absolute path as one named + variable, so the brief has exactly one absolute path and it is not a repo + path. +5. **Fix the brief rather than relaxing the rule.** Isolation is the precondition + for running the workers in parallel at all; turning the guardrail off trades + a stall you can see for main-checkout corruption you cannot. +6. **Dry-run one worker's output contract before fanning out.** Run the exact + write commands from the brief inside a worktree and require them to complete + without an escalation — one probe costs a minute and a bad brief costs every + worker's first phase. + +## Edge cases + +| Case | Then | +|------|------| +| A worker needs the plan another worker produced | The orchestrator copies it into the consuming worker's worktree, or the worker reads it (reads pass); do not have the producer write into the consumer's tree | +| The escalation arrives as a permission prompt in a non-interactive session | It becomes a hard denial — the worker halts with no task-level error, which is why the symptom is a stalled phase rather than a failure | +| The brief names the main checkout only as a read source | It works, and it still couples the brief to one machine's layout — pass it as a named variable so the brief stays portable | +| The guardrail is heuristic and matches on absolute paths | A relative path inside the worktree cannot trip it at all; that is the second reason to write paths relative | +| A worker writes to a path under the main root that is a *sibling* string (`-backup/…`) | The guardrail does not fire — the match requires a path separator after the main root — but the write is still outside the worktree; keep it out of the brief | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| Put the main checkout's absolute path in a worker's `` | Give a worktree-relative path and collect the artifact from the worktree | One absolute write path halts every worker at the same phase, and the coordinator sees only a wait-loop timeout | +| Disable the escape guardrail so the workers proceed | Rewrite the paths in the brief | The guardrail is what makes parallel workers safe to run against one repo | +| Designate a shared scratch directory inside the repo for worker output | Place it outside the repo and pass its path as one named variable | A shared in-repo directory is both a guardrail trip and a write race between workers | + +## Sources + +- https://git-scm.com/docs/git-worktree — linked worktrees are separate checkouts sharing one repository; each has its own working directory +- Field reproduction 2026-08-05 (groundwork guardrails 1.0.0 `hooks/bash-guard.sh`, `worktree_escape` rule, macOS): from a linked worktree, `cp ./a /b` and `echo z > /f` were both stopped; `cat /f`, `ls /.orchestration`, and `grep -n x /f` all passed. The rule matches an absolute main-root mention together with a write verb (`rm|mv|cp|tee|mkdir|touch|install|dd`) or a redirect to an absolute path +- Field context: a parallel run stalled at the same phase for two workers whose brief's `` named a main-checkout absolute path; the coordinator's wait loop returned its escalation status repeatedly. Rewriting the contract to worktree-relative paths let the remaining workers record their plans locally diff --git a/wiki/infrastructure/config/environment-config.md b/wiki/infrastructure/config/environment-config.md index 3b0bf72..0b6f156 100644 --- a/wiki/infrastructure/config/environment-config.md +++ b/wiki/infrastructure/config/environment-config.md @@ -9,7 +9,7 @@ sources: - https://12factor.net/build-release-run - https://12factor.net/dev-prod-parity last_verified: 2026-07-10 -related: [infrastructure-deploy-rollout-and-rollback, infrastructure-ci-cd-secrets-handling, backend-node-boundaries-runtime-validation, backend-python-boundaries-runtime-validation, backend-common-integrations-externally-owned-defaults] +related: [infrastructure-deploy-rollout-and-rollback, infrastructure-ci-cd-secrets-handling, backend-node-boundaries-runtime-validation, backend-python-boundaries-runtime-validation, backend-common-integrations-externally-owned-defaults, platforms-shells-env-var-off-switches] --- # Configuration That Differs Per Environment diff --git a/wiki/infrastructure/index.md b/wiki/infrastructure/index.md index 2bff59c..cc5a6d0 100644 --- a/wiki/infrastructure/index.md +++ b/wiki/infrastructure/index.md @@ -4,10 +4,19 @@ Route here for: CI/CD pipeline design, secrets in build/deploy flows, container image builds, container resource limits and health probes, per-environment configuration (env vars, config drift, startup validation), rollout/rollback strategy, observability (logging, metrics, alerting), datastore backup/restore -and data-loss planning. +and data-loss planning, and orchestrating parallel agent worker sessions +(terminal-pane delivery, completion gates, worktree-isolated briefs). Match your situation to a "load when" line; load only matching pages. +## agent-orchestration + +| Page | Load when | +|------|-----------| +| [pane-delivery-confirmation](agent-orchestration/pane-delivery-confirmation.md) | An orchestrator drives another program through a terminal multiplexer (`tmux send-keys` + `capture-pane`) and must decide whether the input was consumed, retry, or escalate; a pane diff is being used as delivery evidence; the target echoes but never runs the input | +| [session-completion-gates](agent-orchestration/session-completion-gates.md) | Writing a Stop/completion hook that blocks a worker session from ending while its phase is non-terminal; the gate fires on a worker that followed its own prompt; deciding the terminal phase set, the unknown-phase default, and how the gate bounds its own repetition | +| [worktree-isolated-workers](agent-orchestration/worktree-isolated-workers.md) | Authoring the brief/output contract for parallel workers each confined to its own git worktree; workers stall at the same phase with no task-level error; deciding where shared or produced artifacts live and which direction (read vs write) a worktree guardrail stops | + ## ci-cd | Page | Load when | diff --git a/wiki/platforms/index.md b/wiki/platforms/index.md index b97fa71..d4c3b21 100644 --- a/wiki/platforms/index.md +++ b/wiki/platforms/index.md @@ -16,6 +16,9 @@ Match your situation to a "load when" line; load only matching pages. | Page | Load when | |------|-----------| | [portable-shell-scripts](shells/portable-shell-scripts.md) | Writing a shell script that must run on more than one machine/OS/shell or in CI; a script that works locally fails elsewhere; choosing a shebang (bash vs sh); a bash script misbehaves in zsh or vice versa (unquoted vars, `=word`, array indexing); deciding how `set -euo pipefail` protects (and doesn't); building argument lists safely | +| [exit-status-vs-diagnostics](shells/exit-status-vs-diagnostics.md) | Wrapping a compiler/linter/validator in a gate (CI step, git hook, agent tool-use hook) that must surface warnings, not only failures; a gate reports clean on a file the tool complained about; choosing which stream to capture and in which redirection order | +| [option-like-argument-values](shells/option-like-argument-values.md) | Interpolating text you did not author (user input, model output, file contents, a message body) into a command as an operand; a call fails with "unknown flag"/"invalid option" on text that is correct as data; deciding where `--` belongs and what to do when a program ignores it | +| [env-var-off-switches](shells/env-var-off-switches.md) | Disabling part of a script from outside with an environment variable, or writing the switch that reads one; a feature you turned off keeps running silently; choosing between `${VAR:-default}` and `${VAR-default}`; passing a sentinel value when you cannot edit the script | | [command-text-inspected-before-execution](shells/command-text-inspected-before-execution.md) | A hook, policy gate, allow-list, or audit rule blocked a command that is correct as written; composing a command that must satisfy such a gate first try; deciding whether to write a path literally or as `"$VAR"` in an inspected argument; a gate reports an argument missing or a file nonexistent though both are right; a gate must read a file your command creates; prose containing a dangerous-looking command (release notes, docs, fixtures) trips a text scanner | ## tools diff --git a/wiki/platforms/processes/non-interactive-cli-invocation.md b/wiki/platforms/processes/non-interactive-cli-invocation.md index da7927c..a37c9cb 100644 --- a/wiki/platforms/processes/non-interactive-cli-invocation.md +++ b/wiki/platforms/processes/non-interactive-cli-invocation.md @@ -11,7 +11,7 @@ sources: - https://git-scm.com/docs/git - https://man7.org/linux/man-pages/man1/timeout.1.html last_verified: 2026-07-31 -related: [platforms-processes-background-services, platforms-tools-bsd-vs-gnu-cli, platforms-shells-portable-shell-scripts, debugging-methodology-hypothesis-testing] +related: [platforms-processes-background-services, platforms-tools-bsd-vs-gnu-cli, platforms-shells-portable-shell-scripts, debugging-methodology-hypothesis-testing, infrastructure-agent-orchestration-pane-delivery-confirmation] --- # Invoking a Prompt-Capable CLI from a Script or Agent Harness diff --git a/wiki/platforms/shells/env-var-off-switches.md b/wiki/platforms/shells/env-var-off-switches.md new file mode 100644 index 0000000..2631486 --- /dev/null +++ b/wiki/platforms/shells/env-var-off-switches.md @@ -0,0 +1,75 @@ +--- +id: platforms-shells-env-var-off-switches +domain: platforms +category: shells +applies_to: [bash, zsh, posix-sh] +confidence: verified +sources: + - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html +last_verified: 2026-08-05 +related: [platforms-shells-portable-shell-scripts, infrastructure-config-environment-config, platforms-environment-path-resolution] +--- + +# Turning a Script's Behavior Off Through an Environment Variable + +## When this applies + +You are disabling part of a shell script from the outside — `FEATURE= script.sh`, +`CHECK="" ./run` — or you are writing the script that reads such a switch. Also +when a feature you believe you turned off keeps running, with no error and no +message naming the switch. + +## Do this + +1. **Read the script before choosing the value it gets.** Which expansion it + uses decides whether an empty string means anything: + +| Script reads | `VAR` unset | `VAR=` (empty) | `VAR=x` | +|--------------|-------------|----------------|---------| +| `${VAR:-default}` | `default` | `default` — the empty value is discarded | `x` | +| `${VAR-default}` | `default` | empty — the empty value is honoured | `x` | + +2. **When the script uses `${VAR:-default}` and you cannot change it, pass a + value that fails the script's own validation** rather than an empty one. + A sentinel path is the reliable form when the default is a command name: + `WATCH_TMUX=/nonexistent-disable ./watch.sh` makes the script's + `command -v "$TMUX_BIN"` fail and take its already-written disabled path. +3. **When you own the script, express the intent in the expansion.** Use + `${VAR-default}` (no colon) for a switch whose empty value means "off", and + keep `${VAR:-default}` for a value that must never be empty. +4. **Give a disable switch its own explicit test** so the intent is readable and + an empty value is unambiguous: + + ```sh + case "${FEATURE_ENABLED:-1}" in + 0|off|false) enabled=0 ;; + *) enabled=1 ;; + esac + ``` + +5. **Log which branch was taken** — one line naming the switch and the resolved + value. A switch that silently does nothing is indistinguishable from a switch + that does not exist. + +## Edge cases + +| Case | Then | +|------|------| +| The script runs under `set -u` | `${VAR-default}` and `${VAR:-default}` both supply a value, so neither trips `set -u`; a bare `$VAR` does | +| The switch selects a binary and the sentinel path might exist | Point at a path under a directory you control and confirm it is absent (`command -v` / `test -x`) before relying on it | +| Passing a sentinel makes the script fail loudly instead of disabling | The script has no disabled path — add one, or skip the whole invocation from the caller | +| The value comes from a `.env` file or CI variable UI | Empty and unset are frequently indistinguishable there (many loaders export the key with an empty value); use an explicit sentinel value such as `off`, never blank | +| A long-lived service reads the switch | Config-shaped switches for a service belong in its validated config schema — [infrastructure-config-environment-config] | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| Disable a feature by exporting `VAR=` | Read the script's expansion first, then pass an empty value only if it uses `${VAR-…}`; otherwise pass a sentinel the script rejects | `${VAR:-default}` substitutes the default for unset **and** null, so the empty value is discarded and the feature stays on | +| Write `${VAR:-default}` for every option in a script | Use `${VAR-default}` where an empty value is a meaningful choice | The colon form makes "explicitly blank" unreachable from the caller | +| Conclude the switch does not exist when the feature keeps running | Grep the script for the variable and read the expansion form | A feature still running with the switch set is being overridden by the script's own default, which the expansion form shows | + +## Sources + +- https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html — Parameter Expansion: "use of the in the format shall result in a test for a parameter that is unset or null; omission of the shall result in a test for a parameter that is only unset" +- Field reproduction 2026-08-05 (zsh/bash, macOS): with `V=""`, `${V:-def}` → `def` and `${V-def}` → empty; with `V` unset both → `def`. Origin case: `WATCH_TMUX=` failed to disable a liveness check whose script read `TMUX_BIN="${WATCH_TMUX:-tmux}"`, so the check kept running and aborted the run on stale tmux sessions; `WATCH_TMUX=/nonexistent-tmux-disable` took the intended `command -v` failure path diff --git a/wiki/platforms/shells/exit-status-vs-diagnostics.md b/wiki/platforms/shells/exit-status-vs-diagnostics.md new file mode 100644 index 0000000..8298039 --- /dev/null +++ b/wiki/platforms/shells/exit-status-vs-diagnostics.md @@ -0,0 +1,82 @@ +--- +id: platforms-shells-exit-status-vs-diagnostics +domain: platforms +category: shells +applies_to: [bash, zsh, posix-sh] +confidence: verified +sources: + - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html + - https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html + - https://code.claude.com/docs/en/hooks +last_verified: 2026-08-05 +related: [platforms-shells-portable-shell-scripts, testing-quality-checks-that-cannot-pass, platforms-processes-non-interactive-cli-invocation] +--- + +# A Wrapped Tool Reports Warnings on stderr and Exits 0 + +## When this applies + +You are wrapping a compiler, linter, type checker, or validator in an automated +gate — a CI step, a git hook, or an agent tool-use hook — and the gate must +surface that tool's **warnings**, not only its hard failures. Also when a gate +you already wrote reports "clean" on a file the tool visibly complained about. + +## Do this + +1. **Decide from the diagnostic stream, not the exit status.** Capture stderr + with stdout discarded, then branch on emptiness: + + ```sh + OUT=$(tool "$f" 2>&1 >/dev/null) + [ -n "$OUT" ] && { printf '%s\n' "$OUT" >&2; exit 2; } + ``` + +2. **Write the redirections in that order.** They are evaluated left to right, + so `2>&1` first duplicates stderr onto the *current* stdout (the capture), + and `>/dev/null` then moves stdout away. Reversing them captures the tool's + stdout — the build artifact or IR — and feeds that back as if it were a + diagnostic. + +3. **Map the three states the tool can be in**, and give each its own gate + outcome: + +| Tool state | Exit status | Captured stderr | Gate outcome | +|------------|-------------|-----------------|--------------| +| Clean | 0 | empty | pass silently | +| Diagnostics that are not failures (warnings, deprecations, lints) | 0 | non-empty | surface the text as the failure payload | +| Hard error | non-zero | non-empty | surface the text and fail | + +4. **Emit the captured text on the gate's own stderr** and exit with the status + the harness reads as "feed this back". For a Claude Code `PostToolUse` hook + that status is 2 — "Shows stderr to Claude; the tool already ran" — so the + model receives the warning text verbatim instead of a bare failure. + +5. **Prove all three states before adopting the gate**: run it on a file with a + warning, a clean file, and a file with a real error, and require the three + distinct outcomes above ([testing-quality-checks-that-cannot-pass] owns the + known-good-input discipline). + +## Edge cases + +| Case | Then | +|------|------| +| The tool writes diagnostics to **stdout** instead of stderr (some linters, `--format json` modes) | Capture stdout (`OUT=$(tool "$f" 2>/dev/null)`) after confirming which stream carries them on that version; check with `tool f 1>/dev/null` and `tool f 2>/dev/null` separately | +| The tool prints a progress or summary banner on stderr even when clean | Match the diagnostic shape rather than emptiness (`grep -E 'warning|error'`), and keep a clean-file run in the adoption check to prove the banner alone does not trip the gate | +| Warnings must not fail the gate, only be reported | Keep the same capture and print the text, exiting 0 — the capture is what makes the report possible either way | +| The tool is run through a pipeline (`tool f \| tee log`) | Capture into a variable or file first, then inspect; a pipeline reports the last command's status and the diagnostics land in the pipe | +| The tool offers `-Werror` / `--max-warnings 0` | Use it **in addition** — it converts the status, and the captured text is still what names which warning fired | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| Gate on `if ! tool "$f"; then …` | Capture stderr and branch on whether it is empty | A warning is by definition not a failure, so the tool exits 0 and the gate never fires | +| Write `OUT=$(tool "$f" 2>/dev/null >&1)` | Write `OUT=$(tool "$f" 2>&1 >/dev/null)` | Redirections apply left to right; the reversed form discards stderr and captures the tool's stdout payload | +| Trust "the gate stayed quiet" as proof the file is clean | Run the gate once against a file you know produces a warning and require it to fire | A gate keyed on exit status is silent for the clean case and the warning case alike | + +## Sources + +- https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html — Redirection: "If more than one redirection operator is specified with a command, the order of evaluation is from beginning to end" +- https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html — warnings are diagnostics that do not prevent compilation; `-Werror` exists precisely because they otherwise leave the exit status successful +- https://code.claude.com/docs/en/hooks — `PostToolUse` exit code 2: "Shows stderr to Claude; the tool already ran" +- Field reproduction 2026-08-05 (Apple clang, macOS): `cc -Wall -c w.c` with an unused variable → exit 0 with `warning: unused variable` on stderr; clean source → exit 0 with empty stderr; undeclared identifier → exit 1. Same run: `2>&1 >/dev/null` captured `STDERR-DIAG`, `2>/dev/null >&1` captured `STDOUT-PAYLOAD` diff --git a/wiki/platforms/shells/option-like-argument-values.md b/wiki/platforms/shells/option-like-argument-values.md new file mode 100644 index 0000000..bfafae1 --- /dev/null +++ b/wiki/platforms/shells/option-like-argument-values.md @@ -0,0 +1,74 @@ +--- +id: platforms-shells-option-like-argument-values +domain: platforms +category: shells +applies_to: [bash, zsh, posix-sh] +confidence: verified +sources: + - https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html + - https://man7.org/linux/man-pages/man1/tmux.1.html +last_verified: 2026-08-05 +related: [platforms-shells-portable-shell-scripts, platforms-shells-command-text-inspected-before-execution, infrastructure-agent-orchestration-pane-delivery-confirmation] +--- + +# Passing Text You Did Not Author as a Command Operand + +## When this applies + +A script interpolates text it does not control — user input, a model's output, a +file's contents, a message body — into a command as an operand: `tmux send-keys +-l "$text"`, `grep "$pat" f`, `rm "$name"`, `git commit -m "$msg"`. Also when +such a call fails with "unknown flag" or "invalid option" on text that is +correct as data. + +## Do this + +1. **Put `--` between the last option and the first operand**, always, not only + when the value looks suspicious: + + ```sh + tmux send-keys -t "$session" -l -- "$text" + grep -e "$pattern" -- "$file" + ``` + + POSIX Guideline 10 makes `--` the delimiter: arguments after it "should be + treated as operands, even if they begin with the '-' character." + +2. **Treat this as parsing, not quoting.** Quoting decides how the *shell* + splits the word; `--` decides how the *called program* classifies it. A + value that survives quoting intact still reaches the program as a single + word starting with `-`, which its option parser claims. + +3. **Check the call's exit status against a specific meaning**, not against + "something happened". A rejected `send-keys` exits 1 with the payload never + delivered; a caller that reads any nonzero status as "nothing to send" + reports success for a lost message. + +4. **For a program whose option parser does not honour `--`**, pass the value + through a channel that is not the argument vector: stdin (`cmd <` during the test run | That warning IS the misencoded-payload signal — turn warnings into errors for the test suite so it fails instead of passing green | + +## Instead of + +| If you are about to | Do this instead | Why | +|---------------------|-----------------|-----| +| End the test at `assert response.status_code == 303` | Read the record back and assert the values you sent | A request whose body never decoded still produces the success status | +| Pass repeated fields as `data=[("k", "a"), ("k", "b")]` | Pass `data={"k": ["a", "b"]}` | httpx form-encodes only a `Mapping`; a list of tuples is sent as raw content and the server receives an empty form | +| Debug a "saved as empty" record by reading the handler | Assert the outgoing request's content-type and body first | The record is empty because the form decoded to nothing, not because the handler dropped it | + +## Sources + +- https://github.com/encode/httpx/blob/master/httpx/_content.py — `encode_request`: `if data is not None and not isinstance(data, Mapping): warnings.warn("Use 'content=<...>' to upload raw bytes/text content.", DeprecationWarning); return encode_content(data)`. `encode_urlencoded_data` expands a `list`/`tuple` value into repeated `(key, item)` pairs and sets `Content-Type: application/x-www-form-urlencoded` +- https://www.python-httpx.org/quickstart/ — `data={...}` sends form-encoded data; raw bodies belong to `content=` +- Field reproduction: an onboarding step-3 smoke test sent repeated fields as a list of tuples and received 303 while `preferred_types`, `residence_history`, and `partners` all persisted empty; the identical request with a dict of lists persisted correctly. The status code was identical in both runs