From 91e25a3e7a82a6a52ebbce88d7e751f95ff142e7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 31 Aug 2026 15:41:29 +0200 Subject: [PATCH] =?UTF-8?q?docs(governance):=20D-263=20=E2=80=94=20the=20p?= =?UTF-8?q?rimary=20user=20is=20an=20agent,=20and=20that=20changes=20thing?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stated plainly because the record was quietly assuming otherwise: Jeroen runs make and plays the game; the caller typing reach all day is Claude. It resolves several arguments in the opposite direction from human-CLI instinct. --help is a discovery mechanism rather than documentation, since it is how the tool gets relearned from nothing every session — which makes the domain list and closed-set enumeration load-bearing rather than polish. Output volume is a context cost, so quiet-by-default is right for a better reason than not spamming a hook. Latency matters less than legibility: nobody drums their fingers at 300 ms, but a multi-minute silence is expensive because a wedge is indistinguishable from work. And errors that name the next command are the highest-value requirement here, because the reader is usually deciding what to run next — "no" costs a whole exploratory turn. One correction follows directly. D-263 had scoped streaming to "callers with no escape — a human terminal, a Makefile, a git hook", reasoning that Claude Code's background mode already solved the timeout for agents. That got the audience backwards. Background mode solves the timeout and nothing else: it returns when the process exits, so a nine-minute wedge still looks exactly like nine minutes of work. Streaming is what makes a long run legible while it runs, and reattach is worth most to the caller whose attention is not continuous. Both are primary-user features, not fallbacks. Co-Authored-By: Claude Opus 5 (1M context) --- .pql/changelog/ticket_history/2026-08.sql | 33 +++++++++++++++++++++++ .pql/changelog/tickets/2026-08.sql | 18 +++++++++++++ governance/decisions/architecture.md | 10 ++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 7b9635cb8..9dc693e44 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -1836,3 +1836,36 @@ CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per dom REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids. SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lands BEFORE the per-domain ports, so every ported command arrives already streaming. (2) Old scripts retire PER DOMAIN, the moment that domain''s port passes a parity test — not in one sweep at T-1253. So each per-domain port ticket owns its own retirement, and the tree shrinks continuously rather than existing twice for months while edits risk landing in the dead copy. (3) Domain names as mapped are confirmed — no renames. (4) Per D-263''s amended make/reach split, a per-domain port also RETIRES that domain''s tooling make targets rather than leaving wrappers; build/test orchestration targets stay with make.', NULL, '2026-08-31 13:38:09', '2026-08-31 13:38:09.189', '2026-08-31 13:38:09.189', NULL, '3ed37cd81f1112ac323a7797c594de84', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'description', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix. + +DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this. + +DELIVERED — TWO entries, not one. +Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix. +The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252. + +VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning. +Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt. +BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted". +WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green. +What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof. + +FINDING — env-prefixed calls will still prompt, and that is accepted. +Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix. + +DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this. + +DELIVERED — TWO entries, not one. +Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix. +The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252. + +VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning. +Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt. +BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted". +WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green. +What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof. + +FINDING — env-prefixed calls will still prompt, and that is accepted. +Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does. + +REVISITED 2026-08-31: the env-prefix limitation this ticket recorded as ''accepted'' deserves a second look, because the primary user of reach is an AGENT (D-263, stated 2026-08-31), not a human at a terminal. Permission rules prefix-match the whole command string, so SR_REPO_ROOT=... reach ... and SR_OUTPUT_FORMAT=text reach ... do not match Bash(reach *) and will prompt. I called that acceptable on the grounds that an env override is a departure from normal invocation — which is true for a human and less true for me, since the override forms are exactly what a test harness and a debugging session reach for. NOT changing it here: the ordinary form is frictionless, the override forms are rare in normal use, and a broad Bash(env *) grant would be a much larger permission surface for a small convenience. Recorded so the tradeoff is visible if it starts costing prompts in practice — if it does, the fix is a narrow rule per variable, not a blanket one.', NULL, '2026-08-31 13:41:02', '2026-08-31 13:41:02.169', '2026-08-31 13:41:02.169', NULL, '00216fed794c544c5b7e8264f83e835f', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 4a15e20ee..b75c617c8 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -2120,3 +2120,21 @@ CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per dom REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids. SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lands BEFORE the per-domain ports, so every ported command arrives already streaming. (2) Old scripts retire PER DOMAIN, the moment that domain''s port passes a parity test — not in one sweep at T-1253. So each per-domain port ticket owns its own retirement, and the tree shrinks continuously rather than existing twice for months while edits risk landing in the dead copy. (3) Domain names as mapped are confirmed — no renames. (4) Per D-263''s amended make/reach split, a per-domain port also RETIRES that domain''s tooling make targets rather than leaving wrappers; build/test orchestration targets stay with make.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:38:09.189', NULL, 'f4baef3d1ff514ca3ee4823cf2c12043', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix. + +DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this. + +DELIVERED — TWO entries, not one. +Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix. +The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252. + +VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning. +Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt. +BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted". +WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green. +What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof. + +FINDING — env-prefixed calls will still prompt, and that is accepted. +Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does. + +REVISITED 2026-08-31: the env-prefix limitation this ticket recorded as ''accepted'' deserves a second look, because the primary user of reach is an AGENT (D-263, stated 2026-08-31), not a human at a terminal. Permission rules prefix-match the whole command string, so SR_REPO_ROOT=... reach ... and SR_OUTPUT_FORMAT=text reach ... do not match Bash(reach *) and will prompt. I called that acceptable on the grounds that an env override is a departure from normal invocation — which is true for a human and less true for me, since the override forms are exactly what a test harness and a debugging session reach for. NOT changing it here: the ordinary form is frictionless, the override forms are rare in normal use, and a broad Bash(env *) grant would be a much larger permission surface for a small convenience. Recorded so the tradeoff is visible if it starts costing prompts in practice — if it does, the fix is a narrow rule per variable, not a blanket one.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 13:41:02.168', NULL, '0d424100c4529a4ad0ac8ca2111edfbd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 2acb8934f..eb1cd6dd5 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -2611,6 +2611,14 @@ tooling/ - **Never literally interactive by default.** Any prompt is TTY-gated and suppressible with `--no-input`, which hooks pass unconditionally. `tea`'s interactive prompts *"crash in Claude Code (no TTY)"*; a helpful prompt that hangs a hook is worse than a terse exit code. - **This is enforced by a conformance test, not by discipline** — every registered command must have help at its own level, and every declared failure path must name a next command. A contract nothing checks is a style guide. +**The primary user is an agent, not a human** *(stated 2026-08-31, and it settles arguments the rest of this record was having with itself)*. Jeroen runs `make` and plays the game; the caller typing `reach` all day is Claude. Design consequences, each of which contradicts an instinct applied earlier in this record: + +- **`--help` is the discovery mechanism, not documentation.** An agent reads it instead of grepping the tree. That is why the domain list and the closed-set enumeration are load-bearing rather than polish — they are how the tool is *learned*, every session, from nothing. +- **Output volume is a real cost, not an aesthetic one.** Every emitted line spends the primary consumer's context window. "Quiet by default" is right for a better reason than not spamming the push hook: verbosity bills the caller who can least afford it. The `--verbose` gate on the invocation record stands. +- **Latency matters less than legibility.** An agent does not drum its fingers at 300 ms. The ~500 ms ceiling is therefore a sanity bound, not a target worth optimising toward — but a command that runs for minutes in silence is genuinely expensive, because the caller cannot tell a wedge from work. +- **Failures that name the fix are the highest-value requirement in this record.** The reader of an error message is usually an agent deciding what to run next. A message that says only "no" costs a whole exploratory turn; one that names the command costs none. +- **Machine-readable output is the default path, not the exception.** stderr is a pipe far more often than a terminal, so JSONL-when-not-a-TTY matches reality rather than accommodating an edge case. + **`make` and `reach` split by kind, not by preference** *(settled 2026-08-31)*. The Makefile has 84 targets and is today's front door, so "one CLI for all repo tooling" is not true until that relationship is stated. The boundary is **what the target actually does**, not who calls it: - **`make` keeps genuine build and test orchestration** — cargo, Godot, the test gate, anything that sequences a build. That is what make is for, and `reach` would be a worse version of it. @@ -2628,7 +2636,7 @@ tooling/ - **Streaming is additive to the failure contract, never a replacement for it.** A stream has no single moment of truth: a remedy emitted at line 400 of 900 is technically printed and practically invisible. **The verdict — outcome, exit code, and the command that fixes it — is still printed once, last, where it cannot be missed.** A stream that dissolved the summary would quietly undo the requirement this record cares most about. - **The split follows the `core/` bound, and this is its first real test.** The primitives — emit, spawn, detach, redirect, record — are substrate and live in `core/`. The verbs `list`, `status`, `log`, `wait` have logic and state of their own, so they are a **domain**: `reach jobs …`. A job store in `core/` would be exactly the drift this record warns about. - **Non-negotiable: a detached job's exit code must survive.** A runner that reports "started" and loses the failure is the exit-0 trap from the top of this record, relocated somewhere nothing is watching — which is worse. `reach jobs wait` exits with the job's code, and an unwaited failed job is visible in `reach jobs list`. -- **Where this overlaps the harness, prefer the harness.** Claude Code's `Bash` tool already has a background mode that solves the timeout *for agents*. What `reach` adds is for the callers with no such escape — a human terminal, a Makefile, a git hook — plus durable logs and job history. Scope it there rather than rebuilding what one caller already provides. +- **The harness overlaps but does not cover it** *(corrected 2026-08-31 — see the primary-user note below; the original text here scoped streaming to "callers with no escape, a human terminal, a Makefile, a git hook" on the reasoning that Claude Code's `Bash` background mode already solved the timeout for agents. That got the audience backwards.)* Background mode solves **the timeout** and nothing else: it returns when the process exits, so a nine-minute wedge still looks exactly like nine minutes of work. **Streaming is what makes a long run legible while it runs**, and the reattach path — `reach jobs log ` after a turn has moved on — is worth most to the caller whose attention is not continuous. Both are primary-user features, not fallbacks for callers who lack a better option. **Constraints on execution (these are why the work is sequenced, not why it is hard):**