diff --git a/.abcd/development/agents/CHANGELOG.md b/.abcd/development/agents/CHANGELOG.md index 3f9cd9863..abb56a08f 100644 --- a/.abcd/development/agents/CHANGELOG.md +++ b/.abcd/development/agents/CHANGELOG.md @@ -12,6 +12,17 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. +## 2026-10-10 (iss-2610100626211810 — a question's chip sets the mode) + +### question-drafter 0.1.1 + +PATCH: its generated asking-rules block now says a question's chip sets the +mode when the question is asked and the answer sets it back to managed, so a +question needs no `abcd mode` before it, and keeps `abcd mode` for a stop that +is not a question. The drafter's own behaviour is unchanged: it heads each +question with the chip naming its addressee, as before. Unmeasured, in the +`0.x` band. + ## 2026-10-07 (iss-2610070637562567 — abcd's questions are drafted to fit) ### question-drafter 0.1.0 diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index b0049ac19..adc68b001 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -449,15 +449,15 @@ carries the prompt-router entries it expects. Neither install nor uninstall ever mutates it: the manifest is plugin-static. A missing or malformed manifest surfaces as a non-resolvable diagnostic. -The shipped manifest wires six event types, and every event command is a -resolving shim rather than a plain binary call. Four of them self-provision. -`UserPromptSubmit`, `PreToolUse` and `PreCompact` each attempt +The shipped manifest wires seven event types, and every event command is a +resolving shim rather than a plain binary call. Five of them self-provision. +`UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `PreCompact` each attempt `hooks/bootstrap.sh` only when the plugin-root binary is missing, recording the try in a `.bootstrap.attempt` marker that throttles the next one to a ten-minute window. `UserPromptSubmit`, the hook that runs on every message, declares a 120-second `timeout`, the time a salvage on a slow link has before the host -cancels it; `PreToolUse` and `PreCompact` declare none and take the host's -ten-minute default, so a slow first download finishes there. `SessionStart` +cancels it; `PreToolUse`, `PostToolUse` and `PreCompact` declare none and take +the host's ten-minute default, so a slow first download finishes there. `SessionStart` declares 240 seconds, the script's worst case with room to spare, and `SessionEnd` and `SubagentStop` declare none. Every event that runs the script, `SessionStart` included, names a `statusMessage` the host shows as its spinner @@ -470,7 +470,7 @@ the binary in place the script's own fast path costs a file test and does the provisioning housekeeping that keeps the next plugin update served from the local cache rather than the network, and it is the one place a binary that no longer matches its provenance record is called out. It stamps the same marker, -so the three throttled events see a recent try, and reads no throttle of its +so the four throttled events see a recent try, and reads no throttle of its own. `SessionEnd` and `SubagentStop` are the deliberate exceptions and download nothing: both fire where the host cancels a slow hook rather than wait — one as the session is going away, the other inside a live session as a sub-agent diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 365fbfcb9..aa0cffc5f 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -150,16 +150,18 @@ spc-2609212139593041). The badge reads exactly one of its three labels and never the bare tool name, and its colour ends at the badge: the closing sequence restores the default foreground and background and nothing else, so the rest of the row keeps whatever styling the host gives it. The two moments -the badge must change are two moments the hooks already see. The agent sets -the state with `abcd mode` when it stops to ask, naming whom it addresses; the -guard hook, on the host's question tool, refuses abcd's own question (one -headed in abcd's chip grammar, [`17-guard.md`](17-guard.md)) while the state -reads managed, naming `abcd mode product-thinker` and `abcd mode facilitator`, -and when it admits one it writes a `question_open` marker beside the state in -`.abcd/.work.local/`. The next human message is the answer: the prompt hook -finds the marker, resets the state to managed, clears the marker and says so in -one line on stderr. With no marker the prompt hook changes nothing, so a state -the human set by hand survives the message they type next. A repository abcd +the badge must change are two moments the hooks already see. When the +guard hook, on the host's question tool, admits abcd's own question (one headed +in abcd's chip grammar, [`17-guard.md`](17-guard.md)), it sets the state from +the chip's role, naming whom the question is for, and writes a `question_open` +marker beside the state in `.abcd/.work.local/`; a chipped question is never +refused on the mode. The answer resets it: the question tool's `PostToolUse` +hook, when the answer comes back, or the prompt hook on the next human message +where the host runs no `PostToolUse`, finds the marker, resets the state to +managed, clears the marker and says so in one line on stderr. With no marker +neither hook changes anything, so a state the human set by hand survives the +message they type next. At a stop that is not a question the agent sets the +state itself with `abcd mode`, naming whom it addresses. A repository abcd does not manage, or one without the local tier, has no badge, and its questions are not gated. diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index d8060cb3e..de52b39e1 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -115,8 +115,8 @@ consulting the hazard registry. It decodes the tool's questions into the field view of `internal/core/question` (the host's JSON key names stay in the surface) and decides whether the question is abcd's: a header in abcd's chip grammar, which only abcd's interview pages are taught to write, or a mode -naming somebody, which only `abcd mode` sets and the prompt hook resets on the -next human message (itd-201 decision 10). Any other question is another tool's +naming somebody, which only `abcd mode` and this gate set and the answer resets +(itd-201 decision 10). Any other question is another tool's and runs, unchecked and unmarked, exit 0, wherever it is asked. That includes a question with no abcd chip asked while the mode reads managed, in a managed checkout too: the gate cannot tell an abcd question that forgot its chip from @@ -127,8 +127,9 @@ chip. abcd's question is held to the field limits (`question.CheckLimits` over `question.Default`) wherever the hook runs, managed or not: the setup interview asks before a repository is managed, and the limits need no store. The -addressee the product thinker's register rule reads is the mode's where a mode -store exists, and the chip's role word where none does; the binary's verb list +addressee the product thinker's register rule reads is each tab's chip role +for a chipped question, since the mode the gate then sets follows the chip, +and the mode's for a question without a chip; the binary's verb list is read from the command tree and passed in. The row limit is estimated with the host's frame measured at 80 by 24, and a side preview on any option is refused, because the host hides every option's description while a preview @@ -152,8 +153,12 @@ sets no `permissionDecision`, which would bypass the host's own permission flow, and never tells the agent to ask again, because the person may already have answered. A refusal for that question was a redraft of a question the person was ready to answer, and a tab cut at the foot of a short window costs -less. Beside any other finding, or while the mode gate refuses, the rows -finding is named in the deny with the rest. A question with no abcd chip, +less. Beside any other finding, the rows finding is named in the deny but is not one of the parts to fix +(iss-2610100626327722): the head line counts only the findings that refuse, +and the rows findings follow them, bounded the same way, under one line saying +the rows limit does not refuse on its own and the question would have been +shown. Listed among the parts to fix, it read as a cause, and the agent cut a +question the gate would have shown. A question with no abcd chip, abcd's only because the mode names somebody, carries one line under the head line saying so, naming the mode's person, so an agent whose question another tool wrote does not loop on it. The hook refuses and never @@ -166,25 +171,32 @@ and a question tool fails open in the question's wording; a payload whose tool cannot be read fails open naming no tool. Neither echoes the decoder's text, which can name a Go type. -In a repository abcd manages, abcd's question asked while `abcd mode` reads -managed is also refused with the host's deny. Alone, the refusal is one -line naming the two settings, `abcd mode product-thinker` and `abcd mode -facilitator`, so the agent says whom it is asking before it asks and the -status-line badge shows it; beside field findings, that line follows them. Once -the state names somebody the question runs, and the hook writes a -`question_open` marker in `.abcd/.work.local/`; the rules loader's prompt hook -resets the state to managed on the next human message and clears the marker. -Where the badge does not show, the mode gate does not run. A state file or -marker the gate cannot read or write is not a decision: the question runs and -the hook says so on exit 1, the same fail-open-loud contract below. So is a -tier `abcd mode` cannot write — a read-only mount, a directory the session's -user does not own: the refusal's remedy could not run there, so the gate probes -that the verb could set the state before it refuses, and where it could not, -the mode refuses nothing and only field findings, whose remedy is the agent's -own, can refuse; with none, the question runs on exit 1. -The probe is a file created in the tier and removed again; one the remove could -not reach, because the tier turned unwritable in between or the process died, -is swept by the next probe. The manifest's pre-tool-use matcher names the +In a repository abcd manages, abcd's question is never refused on the mode +(iss-2610100626211810). Its chip already names whom it is for, and refusing it +while the mode read managed refused an interview intermittently, whenever the +mode had been reset between two questions. So when the gate admits a question +carrying abcd's chip, it derives the state from the chip's role and sets it: +`Product` names the product thinker, and `Tech` and `Setup` name the technical +facilitator. `Setup` is the setup interview's chip, whose questions (the +repository's visibility, its keys, the oracles it connects) are about how abcd +is installed and run, the facilitator's half; a call whose tabs name both +people sets the product thinker. A chip naming someone other than the mode +names re-sets the mode, because the status line names whom the question on +screen is for. The gate writes a `question_open` marker in +`.abcd/.work.local/` first and sets the state second, so a failure between the +two leaves a marker whose reset costs nothing rather than a parked state with +nothing left to reset it. The answer resets the state to managed and clears the +marker: the plugin's `PostToolUse` hook on the question tool runs the reset when the +answer comes back, and the rules loader's prompt +hook runs the same reset on the next human message, the fallback for a host +that does not run `PostToolUse`; whichever runs first clears the marker, so +the other changes nothing. `abcd mode` remains the way to record whose answer +is owed at a stop that is not a question. A question without abcd's chip while +the mode names somebody is held to the field limits and marked open, and sets +nothing. Where the badge does not show, the mode is neither read nor set. A +state file or marker the gate cannot read or write, a read-only tier among +them, is not a decision: the question runs and the hook says so on exit 1, the +same fail-open-loud contract below. The manifest's pre-tool-use matcher names the shell tool and the question tool, and nothing else. ## Fail-open-loud diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index d12648a5c..b81616029 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -1068,15 +1068,16 @@ checksums; its build strings are printed instructions to the operator for the ca it cannot provision. `SessionStart` runs one chained command rather than siblings, because siblings would run in parallel and share one stdin. -`SessionStart`, `UserPromptSubmit`, `PreToolUse` and `PreCompact` are the events -that reach `bootstrap.sh` at all. The last three self-provision only when the +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `PreCompact` +are the events that reach `bootstrap.sh` at all. The last four self-provision +only when the plugin-root binary is missing, throttled by a `.bootstrap.attempt` marker within a ten-minute window, and then fall back to a PATH-resolved abcd that must be absolute, outside the working directory, in a directory and a file that are not world-writable, and recorded as this machine's own, before failing loudly. `UserPromptSubmit`, which runs on every -message, declares a 120-second `timeout`; `PreToolUse` and `PreCompact` declare -none and take the host's ten-minute default; `SessionStart` declares 240 seconds, +message, declares a 120-second `timeout`; `PreToolUse`, `PostToolUse` and +`PreCompact` declare none and take the host's ten-minute default; `SessionStart` declares 240 seconds, and the transcript hooks declare none. Every event that runs `bootstrap.sh` names a `statusMessage`, the host's spinner text while the hook runs, because the salvage sends the script's output nowhere; the text states no duration, since @@ -1095,6 +1096,10 @@ BLOCKING code on that event. `SubagentStop` carries `hook subagent-stop`, which stages a finished sub-agent's transcript beside it with the lineage the harness payload and its per-agent sidecar supply. `SessionStart` drains both through the fail-closed redaction path. +`PostToolUse` is matched to the question tool alone and carries `hook +question-answered`, which resets the mode to managed when an answer comes back +to a question the guard marked open (iss-2610100626211810); the prompt hook +runs the same reset on the next message, for a host that does not run it. **The plugin-internal development namespace** (committed in private repos, gitignored in public) holds, at its root, the config file and the per-surface diff --git a/.abcd/development/brief/glossary/core/product-thinker.md b/.abcd/development/brief/glossary/core/product-thinker.md index f4c5f8d69..c221eaadc 100644 --- a/.abcd/development/brief/glossary/core/product-thinker.md +++ b/.abcd/development/brief/glossary/core/product-thinker.md @@ -19,8 +19,9 @@ The **product thinker** decides *what*: which intents are pursued, what their ac criteria promise, which proposal is adopted, and every ruling the record carries. `abcd intent plan ` is their sign-off act ([plan](plan.md)), and the planning interview's questions are theirs. They answer on a surface of their own, so a stop that waits on them is announced: -`abcd mode product-thinker` parks the loop on them, and the status line reads `waiting on the -product thinker` until they answer. +a question's `Product` chip, or `abcd mode product-thinker` at a stop that is not a question, +parks the loop on them, and the status line reads `waiting on the product thinker` until they +answer. The product thinker owns two things in the record: the brief, which says what the product is, and the intents, which say what is to be built and in what order. Specs and the issue @@ -48,7 +49,7 @@ Where a sentence genuinely means either person, it says "the person" or names bo ## Examples - "Adopting a proposal is the product thinker's move." -- "Set `abcd mode product-thinker`, then ask the product thinker once, in their own words." +- "Ask the product thinker once, under a `Product` chip, in their own words." ## Related terms diff --git a/.abcd/development/brief/glossary/core/technical-facilitator.md b/.abcd/development/brief/glossary/core/technical-facilitator.md index 82f73f9ff..231f24d5a 100644 --- a/.abcd/development/brief/glossary/core/technical-facilitator.md +++ b/.abcd/development/brief/glossary/core/technical-facilitator.md @@ -18,8 +18,9 @@ versions: null The **technical facilitator** decides *how*: the person at the terminal running the agents, who operates the gates, merges, CI, hooks and installs, and addresses the mechanism and the record ids that the [product thinker](product-thinker.md) is spared. A stop that waits on them is -announced as one: `abcd mode facilitator` parks the loop on them, and the status line reads -`waiting on the technical facilitator` until they answer. +announced as one: a question's `Tech` or `Setup` chip, or `abcd mode facilitator` at a stop +that is not a question, parks the loop on them, and the status line reads `waiting on the +technical facilitator` until they answer. itd-97 (a draft) holds that **the facilitator is a mode, not a person**. A project runs duo, with a human technical facilitator beside the product thinker, or solo, where abcd itself @@ -41,7 +42,7 @@ person" or names both. ## Examples - "abcd writes what is missing and never replaces what the technical facilitator put there." -- "Set `abcd mode facilitator`, ask the technical facilitator first, then pipe the answer." +- "Ask the technical facilitator first, under a `Tech` chip, then pipe the answer." ## Related terms diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index dc77b50e4..3fb6ead9b 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1544,6 +1544,11 @@ "hidden": false, "flags": [] }, + { + "path": "abcd hook question-answered", + "hidden": false, + "flags": [] + }, { "path": "abcd hook session-end", "hidden": false, diff --git a/.abcd/development/specs/open/spc-2610031844158884-under-the-board-a-short-what-next-menu-offers-the-few-steps.md b/.abcd/development/specs/open/spc-2610031844158884-under-the-board-a-short-what-next-menu-offers-the-few-steps.md index d28ae03d5..fb8947ec4 100644 --- a/.abcd/development/specs/open/spc-2610031844158884-under-the-board-a-short-what-next-menu-offers-the-few-steps.md +++ b/.abcd/development/specs/open/spc-2610031844158884-under-the-board-a-short-what-next-menu-offers-the-few-steps.md @@ -85,14 +85,13 @@ Builds on and reuses: forced by `TERM=dumb`, and `term.RawSession`, whose restore runs on every exit. The menu asks through it and holds no keyboard code of its own. - The mode store and its question marker: `mode.ReadAt` and `mode.SetAt` - (`store.go`, lines 70 and 109), `mode.MarkQuestionOpen`, - `mode.ResetOnAnswer` and `mode.CanSet` (`question.go`, lines 57, 114 - and 165). -- The session's status rules, unchanged: the guard refuses a chip-headed - question while the mode reads managed (`questionGate`, - `guard_question.go`, line 82, naming `abcd mode` in `questionRefusal`, - line 52), marks it open when it admits one (line 137), and the prompt hook - resets it on the answer (`resetModeOnAnswer`, line 275). + (`store.go`), `mode.MarkQuestionOpen` and `mode.ResetOnAnswer` + (`question.go`). +- The session's status rules, unchanged: when the guard admits a + chip-headed question it marks it open and sets the mode from the chip's + role (`questionGate`, `guard_question.go`), and the question tool's + PostToolUse hook resets it when the answer returns, with the prompt hook's + reset as the fallback (iss-2610100626211810). - The build's start: `loop.Start` (`loop.go`, line 263), its runner read (`loadRunners`, `build.go`, line 927), and its refusal, `loop.Refusal` (`refusal.go`, line 15), whose `Check` names the pre-start check @@ -202,8 +201,9 @@ alone, writes nothing, and emits no escape byte (B3; decision 10): drawn on stderr, as the answer loop draws every question; - neither `--json` nor `--format markdown` nor `--no-menu` is passed; - the off setting does not say off; -- the repository is managed (`ahoy.Managed`) and its local tier can take the - write (`mode.CanSet`). +- the repository is managed (`ahoy.Managed`) and has its local tier + (`mode.HasTier`); a write the tier then refuses is reported, never a + refusal of the board. A new call in `internal/core/mode` holds the status for a question abcd asks itself: diff --git a/.abcd/work/issues/open/iss-2610100846469473-testrebuildinginplaceisbyteidentical-internal-core-site.md b/.abcd/work/issues/open/iss-2610100846469473-testrebuildinginplaceisbyteidentical-internal-core-site.md index 1150cfa0b..3b801751d 100644 --- a/.abcd/work/issues/open/iss-2610100846469473-testrebuildinginplaceisbyteidentical-internal-core-site.md +++ b/.abcd/work/issues/open/iss-2610100846469473-testrebuildinginplaceisbyteidentical-internal-core-site.md @@ -9,7 +9,9 @@ found_during: "the records branch preflight after v0.13.4, 2026-10-10" origin: researcher-authored production_mode: hand-written found_at: "internal/core/site" -remedy: "Find the WaitDelay the root lookup's git call sets and make a timeout under load distinguishable from a real failure to name the root: retry the lookup once, or report the timeout as its own error rather than as 'git cannot name its root'; reproduce with the test under a CPU-bound load before and after." +remedy: "Make a git call that misses its WaitDelay under load distinguishable from a real answer in both lookups the site build makes: the root lookup, which reports the timeout as 'git cannot name its root', and gitutil.RootCommit (internal/gitutil/repo.go:281), which folds every rev-list error into an empty root commit; retry once or return the timeout as its own error, and reproduce with the test under a CPU-bound load before and after." --- TestRebuildingInPlaceIsByteIdentical (internal/core/site) failed in make preflight on 2026-10-10 with 'sits inside a checkout but git cannot name its root (exec: WaitDelay expired before I/O complete (stderr: ""))' while the machine's five-minute load average was about 48 on 16 cores, from concurrent sessions' preflights; the same test passed three times in a row once the load fell. The site build's root lookup turns a git subprocess that misses its WaitDelay into a refusal, so a loaded machine fails a records-only branch's preflight on code it did not touch. + +A second symptom, reported by a peer session at about 08:40Z on 2026-10-10: the same test failed in the race lane of another branch's preflight with 'build: site: /003 is not empty, and this repository has no root commit for a .abcd-site-build to name ... refusing to remove it'. The test's first build succeeded and its second got an empty root commit. gitutil.RootCommit (internal/gitutil/repo.go:281) folds every rev-list error into "", so the same WaitDelay miss under load reads as a repository with no root commit. Five isolated race-enabled reruns passed. diff --git a/.abcd/work/issues/open/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md b/.abcd/work/issues/resolved/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md similarity index 59% rename from .abcd/work/issues/open/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md rename to .abcd/work/issues/resolved/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md index d79b55059..f269fa013 100644 --- a/.abcd/work/issues/open/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md +++ b/.abcd/work/issues/resolved/iss-2610100626211810-an-abcd-question-is-refused-with-the-mode-reads-managed.md @@ -10,6 +10,16 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/guard_question.go" remedy: "Derive the mode from the chip when the question gate admits a question (Product to product-thinker, Tech to facilitator, Setup ruled in the fix) and set it there, so a chipped question is never refused on the mode; reset to managed when the answer returns, from a PostToolUse hook on the question tool, rather than on the next prompt; drop the set-before and set-back steps from the asking rules and the mode page, keeping abcd mode for a stop that is not a question." +resolution: "The question gate sets the mode from an admitted question's chip (Product to product-thinker, Tech and Setup to facilitator) and never refuses a chipped question on the mode; a PostToolUse hook on the question tool resets it to managed when the answer returns, with the prompt hook as the fallback; the asking rules and interview pages keep abcd mode for a stop that is not a question." +impact: fix +resolved_by: + commit: "984be06e5" --- An abcd question is refused with 'the mode reads managed' whenever the agent has not run abcd mode just before it, although the question's own chip ('Tech Q3', 'Product Q2') already names whom it is for. The mode is reset two ways: the asking rules tell the agent to set it back to managed after every answer, and the prompt hook resets it on the next prompt the session receives while a question is marked open, which in a turn of several questions can be a peer session's message or a message the person sends mid-turn (inferred from a refusal on 2026-10-10 after a peer message arrived; not yet reproduced). So an interview of several questions is refused intermittently, and the person sees the hook's error line each time (iss-2610070637562567 measured that every refusal reaches them framed as an error). The product thinker chose on 2026-10-10 to fix this in code first, before the general rewriter. + +Verified live on 2026-10-10 on Claude Code 2.1.296, in a scratch folder whose only hooks logged the question tool's PreToolUse and PostToolUse events: an answered question fired PostToolUse with `tool_name` AskUserQuestion, `cwd`, and the answers in `tool_response`; a question dismissed with Esc fired PreToolUse and no PostToolUse, so a dismissed question leaves the mode set until the next prompt's reset, the fallback this fix keeps. + +## Grounds + +- pursued: an interview of several chipped questions is never refused on the mode and the status line names whom each question is for; a chipped question refused with 'the mode reads managed', or a mode still parked after the answer on a host that runs PostToolUse, would show it wrong diff --git a/.abcd/work/issues/open/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md b/.abcd/work/issues/resolved/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md similarity index 70% rename from .abcd/work/issues/open/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md rename to .abcd/work/issues/resolved/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md index 53b5240be..f58421aae 100644 --- a/.abcd/work/issues/open/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md +++ b/.abcd/work/issues/resolved/iss-2610100626327722-when-an-abcd-question-is-refused-for-another-fault-the.md @@ -10,6 +10,12 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/guard_question.go" remedy: "In a refusal for another fault, list a rows overrun after the refusing parts, on a line saying it does not refuse on its own and the question would be shown, with a test that a mode or layout refusal carrying a rows overrun says so." +resolution: "A refusal for another fault now counts and lists only the refusing parts; a rows overrun follows them under a line saying the rows limit does not refuse on its own and the question would have been shown." +impact: fix --- When an abcd question is refused for another fault, the refusal lists the question's rows overrun among the parts to fix ('fix each and ask again'), although the rows limit alone never refuses (the question gate's rowsOnly, iss-2610070637562567). The person and the agent read it as a cause of the refusal, and the agent cuts a question the gate would have shown; seen twice in one interview on 2026-10-10. + +## Grounds + +- pursued: the agent stops cutting a question for its height when another part refused it; a refusal that still lists a rows finding among the parts to fix would show it wrong diff --git a/agents/question-drafter.md b/agents/question-drafter.md index 55b6d5665..5d4431e65 100644 --- a/agents/question-drafter.md +++ b/agents/question-drafter.md @@ -1,7 +1,7 @@ --- name: question-drafter description: Draft one of abcd's questions so it follows every asking rule and fits the rows limit — given the material to quote, the addressee, the decision and the defensible answers, return the host question tool's input and the rows each tab takes. Never puts anything to the person itself. -prompt_version: 0.1.0 +prompt_version: 0.1.1 reads_untrusted_input: true capability_scope: task_classes: [surface_render] @@ -101,8 +101,8 @@ The question you return follows every one of these rules, the rules abcd's quest - In abcd's own interviews, address the person in their register: The product thinker gets outcomes and choices in product terms, with no record ids, no code, and no internals; the technical facilitator gets the mechanism, the ids, and the trade-offs. - In abcd's own interviews, when it is not known which role the person holds, the first question asks that, and the mode records the answer so the next question does not ask again. - What each person can be assumed to know, the knowledge floor an explanation is measured against, is stated in full at .abcd/development/brief/glossary/interview/knowledge-floor.md under abcd's plugin root (in abcd's own repository, at that path from its root). -- In abcd's own interviews, before stopping for an answer, record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. Where the host has no status surface, the set form prints one line naming the addressee; relay it verbatim, because that line is the whole of the fallback. -- In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator); when it differs from the current mode, set the mode, then ask, and the chip names that role. The status line names the person the question on screen is for, so a mixed interview re-sets the mode per question, never once at the start. +- In abcd's own interviews, before a stop that is not a question (a hand-off, or a step the person runs), record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. Where the host has no status surface, the set form prints one line naming the addressee; relay it verbatim, because that line is the whole of the fallback. +- In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator), and head the question with the chip naming that role: the chip sets the mode when the question is asked, and the answer sets it back to managed, so a question needs no `abcd mode` before it. The status line names the person the question on screen is for, so a mixed interview re-sets the mode per question, never once at the start. ## Counting rows diff --git a/commands/ahoy.md b/commands/ahoy.md index ae0de59f2..cecb25ba3 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -238,7 +238,7 @@ still sets the value in a scripted run, and the question returns on its own once a second answer has an adapter. That is a channel for passing on an answer the technical facilitator has GIVEN -— set `abcd mode facilitator`, ask the technical facilitator first, then pipe; +— ask the technical facilitator first, under a `Tech` chip, then pipe; it is never a licence to answer on their behalf. Note that `yes |` approves EVERY question, so only reach for it once they have agreed to all of them. @@ -584,8 +584,8 @@ caller must CONFIRM the specific toggles named. A repo that sets as it is and is not contacted at all. The confirmation is the fourth gate, not a formality: an unanswered run declines -and changes nothing, so set `abcd mode facilitator` and present the question -and the repository it names to the technical facilitator before answering it. +and changes nothing, so present the question and the repository it names to +the technical facilitator, under a `Tech` chip, before answering it. `--yes` says yes in advance, and it is the technical facilitator's word to give — never pass it on their behalf. A run that changed nothing exits NON-ZERO (`refused` exits 2, as a refused install does; `aborted` exits 1), so a failed @@ -725,9 +725,9 @@ command for the person.** The setup is `abcd ahoy connect --base-url --model [--model …] --home [--key ]`. **This writes, under -`~/.abcd.noindex/` and, for the keychain home, into the platform keychain.** Set -`abcd mode facilitator` and ask the technical facilitator which home through -your question tool, after relaying `key_homes`, and offer the three without +`~/.abcd.noindex/` and, for the keychain home, into the platform keychain.** Ask the +technical facilitator which home through your question tool, under a `Tech` +chip, after relaying `key_homes`, and offer the three without marking one: `external` takes `--env ` or `--file ~/.json --field ` (abcd keeps only where the key is); `abcd` and `keychain` take the key piped in on stdin from a file or a @@ -793,9 +793,9 @@ The walkthrough is `abcd ahoy credential --home `, with the same three homes as `connect`: `external` with `--env`, or `--file` and `--field`; `abcd` and `keychain` with the value piped in on stdin. **This writes the chosen home only after the reading adapter's own verification call succeeds** -(the provider's one short exchange, the hosting provider's account read). Set -`abcd mode facilitator` and ask the technical facilitator for the home through -your question tool; never ask for the value, and never pass it yourself: give +(the provider's one short exchange, the hosting provider's account read). Ask the +technical facilitator for the home through your question tool, under a `Tech` +chip; never ask for the value, and never pass it yourself: give the person the command to run in their own shell and relay `name`, `home`, `verified` and each `wrote` entry. A name another home already holds, or a different value for a name already kept, is refused: abcd never replaces a diff --git a/commands/capture.md b/commands/capture.md index 3d2353245..b41587816 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -77,8 +77,7 @@ Append a structured issue from free-form text: written as the record's `remedy:` field, and `abcd drain` reads it to decide whether the issue needs no decision. A capture without it, or with a blank one, is refused (exit 2, nothing written) with a message naming the flag. When no -fix was given, ask the product thinker for it (set `abcd mode product-thinker` -first); when they have none yet, let them choose the words rather than +fix was given, ask the product thinker for it, under a `Product` chip; when they have none yet, let them choose the words rather than inventing a fix for them. A remedy chosen in an autonomous run cites its grounds in the record's text: where the fix depends on outside practice, a prior-art or state-of-the-art check (principle `prefer-sota`) names what it @@ -148,9 +147,8 @@ Nothing is refused or dropped. The JSON's `match` object carries `matches` `near_misses` (the best five below the threshold, with their scores), `threshold`, and `skipped` when nothing was compared: a text with fewer than eight distinct terms, a record set that could not be read, or a match -configuration the reader refuses. Set `abcd mode facilitator`, relay each -match with its id and relation, and ask the technical facilitator to confirm -it. A confirmed link is left as it is; a wrong one is removed by deleting its +configuration the reader refuses. Relay each match with its id and relation, +and ask the technical facilitator to confirm it, under a `Tech` chip. A confirmed link is left as it is; a wrong one is removed by deleting its line, which leaves an ordinary record. The match never proposes `reverses` or `supersedes`: a reversal is a person's judgement. @@ -359,8 +357,8 @@ The refusal names the construct and its body line. The repair is a hand edit: close or remove the opener in a text editor, then re-run. Promote and resolve given no `--grounds` append nothing and act. -**Ask the product thinker for the expectation and its falsifier** (set `abcd -mode product-thinker` first). "Promoted it because it is next" +**Ask the product thinker for the expectation and its falsifier** (asked under a +`Product` chip). "Promoted it because it is next" restates the decision and records nothing; "promoted it because we expect a stamped identity to survive rewording, which nothing else does" is a conjecture somebody can later find wrong. abcd refuses only the degenerate texts — empty, diff --git a/commands/disembark.md b/commands/disembark.md index 95657753b..b42eb64d4 100644 --- a/commands/disembark.md +++ b/commands/disembark.md @@ -63,8 +63,8 @@ most valuable thing left. Reach that with an explicit flag on any of the three: **Offer it; never assume it.** Widening the scan is the technical facilitator's choice to make, not a default to infer from a repo looking abandoned. When a probe comes back thin over a repo that plainly had work in it, say the scan -honoured `.gitignore`, set `abcd mode facilitator`, and ask the technical -facilitator whether to widen — do not re-run wide on your own judgement. +honoured `.gitignore`, and ask the technical facilitator, under a `Tech` chip, +whether to widen — do not re-run wide on your own judgement. The wide scan declares itself: the report carries `included_ignored: true` (`scope: WIDE` in the text rendering), and the marker scan's `searched` line says diff --git a/commands/docs.md b/commands/docs.md index 697d28c6b..1552a492b 100644 --- a/commands/docs.md +++ b/commands/docs.md @@ -68,7 +68,7 @@ Only URLs the documentation actually cites can be confirmed. The receipt records user for their method, and never record one: the schema has no field for it and loading rejects unknown keys. -Set `abcd mode facilitator` first, then confirm on the technical facilitator's +Ask the technical facilitator under a `Tech` chip, and confirm only on their word that they checked. An agent must never run `confirm` on its own initiative to clear a red gate. diff --git a/commands/embark.md b/commands/embark.md index 0c1873cf9..07c14231a 100644 --- a/commands/embark.md +++ b/commands/embark.md @@ -80,8 +80,8 @@ ranks them against the new voyage's brief by term overlap (a heuristic, named in names, such as the press release the interview is drafting. With neither, the lessons are listed unranked (`unranked: true`). It writes nothing. -Set `abcd mode product-thinker`, then show the product thinker the `top` few (three), each with the release it came from, and -ask which apply, in one question; the `rest` are a list opened on request. +Show the product thinker the `top` few (three), each with the release it came from, and +ask which apply, in one question under a `Product` chip; the `rest` are a list opened on request. That question follows the asking rules in `commands/intent.md` (the block marked `generated: asking-rules`): the lessons themselves first, quoted in full, and the question last. Each lesson arrives cleaned to one inert line and is the diff --git a/commands/guard.md b/commands/guard.md index 9036ed8e0..a2a4b34d1 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -101,20 +101,25 @@ line keeps the full count. The rows limit is the exception: a question whose only finding is that a tab runs past the rows limit is shown, and the hook tells the agent afterwards which tab ran over, by how much, and to draft the next question through the `abcd:question-drafter` agent, which counts rows the way -the check does. A question with no abcd chip, held to the limits +the check does. In a question refused for another part, a tab over the rows +limit is not one of the parts to fix and the head line does not count it: it +follows them, under a line saying the rows limit does not refuse on its own and +that the question would have been shown. A question with no abcd chip, held to the limits because `/abcd:mode` names somebody, carries one more line under the head line saying so: another tool's question asked while the mode names somebody is held to abcd's rules. The hook refuses and never rewrites the question. -In a repository abcd manages, abcd's question asked while `/abcd:mode` reads -`managed` is also refused, and the refusal names `abcd mode product-thinker` -and `abcd mode facilitator`: set the one that names whom you are asking, then -ask again. An admitted question is marked open in -`.abcd/.work.local/question_open`, and the next human message resets the state -to `managed`. Where `abcd mode` could not write the state — a local tier that -is not writable — the mode does not refuse the question, which runs with a loud -`NOT CHECKED` warning rather than a refusal nobody could answer. A questions -field the hook cannot read is the same case: the question runs, and the hook +In a repository abcd manages, an admitted question carrying abcd's chip sets +`/abcd:mode` from the chip's role: `Product` to `product-thinker`, `Tech` and +`Setup` to `facilitator`. So a chipped question is never refused on the mode, +and the status line names whom the question on screen is for, a chip naming +the other person re-setting the mode. An admitted question is marked open in +`.abcd/.work.local/question_open`, and the answer resets the state to +`managed`: the plugin's hook on the question tool's result resets it when the +answer comes back, and the next human message resets it on a host that does +not run that hook. Where the state cannot be written or the question cannot be +marked open, such as a local tier that is not writable, the question runs with +a loud `NOT CHECKED` warning. A questions field the hook cannot read is the same case: the question runs, and the hook warns loudly. So is a payload the hook cannot decode at all: where it names the question tool the warning says the question runs, and where no tool name can be read the warning names no tool. diff --git a/commands/identity.md b/commands/identity.md index 9b287822d..691b342bf 100644 --- a/commands/identity.md +++ b/commands/identity.md @@ -28,8 +28,8 @@ records the block. prints a unified diff per drifted surface. It **writes nothing**, and no flag makes it: adopting a proposal is the product thinker's move, because what the -project says is theirs to decide. Show the diff, set `abcd mode product-thinker`, -then ask the product thinker whether to apply it. If they would rather change +project says is theirs to decide. Show the diff, then ask the product thinker, +under a `Product` chip, whether to apply it. If they would rather change what the project says than what its surfaces say, the fix is an edit to the identity block, after which this same command chases the surfaces. @@ -50,8 +50,7 @@ repointing the canon is a deliberate edit. ### The interview -Set `abcd mode product-thinker`, then ask the product thinker once, in their -own words: +Ask the product thinker once, under a `Product` chip, in their own words: 1. **Title** (required) — what the project is called, as it should read in a heading. diff --git a/commands/ingest.md b/commands/ingest.md index e2e14a431..6c0f4f952 100644 --- a/commands/ingest.md +++ b/commands/ingest.md @@ -41,9 +41,9 @@ Two rules hold for author names: - **Class.** Web content is `public` by default. Signals for `--confidential`: the user says so; it is their own unpublished/submitted work; internal or NDA material; AI-generated content (never citable); a - private repo's documentation. When confidential, set `abcd mode - product-thinker` or `abcd mode facilitator` for whichever of the product - thinker or the technical facilitator is adding the source, then ask them for + private repo's documentation. When confidential, ask whichever of + the product thinker or the technical facilitator is adding the source, under + the chip naming their role, for every identifying name variant (aliases — repo names, codenames, domains) and pick `--permission`: `no-public-citation`, `internal-never-cite`, `ai-generated-never-cite`, or `ask-author`. The class is declared once, here; diff --git a/commands/intent.md b/commands/intent.md index 859920215..918fd1bf6 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -111,8 +111,8 @@ is written onto the draft as `duplicates: []` (the same proposal filed again) or `refines: []` (the other record is broader), at most three links. The create is never refused by the match. The JSON's `match` object carries the matches, the `near_misses` below the threshold with their scores, and `skipped` -when nothing was compared. Set `abcd mode product-thinker`, relay each match -and ask the product thinker to confirm it; a wrong link is removed by deleting +when nothing was compared. Relay each match and ask the product thinker to +confirm it, under a `Product` chip; a wrong link is removed by deleting its line, which leaves an ordinary draft. A single whitespace-free word is refused (exit 2, nothing written): a lone @@ -246,8 +246,8 @@ reconstructed. The refusal covers both, deliberately, because nothing in the enforcement can tell relocated text from invented text — which is why the state those three records are in is not reachable through this verb. -**Ask the product thinker for the expectation and its falsifier** (set `abcd -mode product-thinker` first). "Planned it because it is next" +**Ask the product thinker for the expectation and its falsifier** (asked under a +`Product` chip). "Planned it because it is next" restates the decision and records nothing; "planned it because we expect a stamped identity to survive rewording, which nothing else does" is a conjecture somebody can later find wrong. abcd refuses only the degenerate texts — empty, @@ -338,8 +338,8 @@ any question is a valid answer, but silence is not consent. - In abcd's own interviews, address the person in their register: The product thinker gets outcomes and choices in product terms, with no record ids, no code, and no internals; the technical facilitator gets the mechanism, the ids, and the trade-offs. - In abcd's own interviews, when it is not known which role the person holds, the first question asks that, and the mode records the answer so the next question does not ask again. - What each person can be assumed to know, the knowledge floor an explanation is measured against, is stated in full at .abcd/development/brief/glossary/interview/knowledge-floor.md under abcd's plugin root (in abcd's own repository, at that path from its root). -- In abcd's own interviews, before stopping for an answer, record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. Where the host has no status surface, the set form prints one line naming the addressee; relay it verbatim, because that line is the whole of the fallback. -- In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator); when it differs from the current mode, set the mode, then ask, and the chip names that role. The status line names the person the question on screen is for, so a mixed interview re-sets the mode per question, never once at the start. +- In abcd's own interviews, before a stop that is not a question (a hand-off, or a step the person runs), record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. Where the host has no status surface, the set form prints one line naming the addressee; relay it verbatim, because that line is the whole of the fallback. +- In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator), and head the question with the chip naming that role: the chip sets the mode when the question is asked, and the answer sets it back to managed, so a question needs no `abcd mode` before it. The status line names the person the question on screen is for, so a mixed interview re-sets the mode per question, never once at the start. The knowledge floor every explanation is measured against: [knowledge-floor.md](../.abcd/development/brief/glossary/interview/knowledge-floor.md). @@ -778,8 +778,8 @@ a lift that did not happen. A planned intent may name the release it must land by: `target` writes `target_release: ` onto a record in `planned/`, where the value is a release tag `vX.Y.Z` or `next` (the next release, whatever version it -derives). The target is the product thinker's to name: set `abcd mode -product-thinker` before asking for it, and never infer one. A second +derives). The target is the product thinker's to name: ask them under a +`Product` chip, and never infer one. A second target replaces the first and the JSON names it under `previous`; the same target again writes nothing and reports `written: false`. A draft takes its target as it is planned, with `intent plan --target ` (one @@ -1116,9 +1116,9 @@ condition carries rather than to its wording, and joined to what occasioned it. - **With a condition id** it appends one dated block to `## Audit Notes`: the identity, the value, the occasion and the grounds, with the narrowing under a `narrowed` value. The occasion is a reading item at any position, or - an intent in `shipped/` whose delivery changed the condition's standing. Set - `abcd mode product-thinker` and ask the product thinker, who reads as the - researcher here, for the value and the grounds; the reading names the tension + an intent in `shipped/` whose delivery changed the condition's standing. Ask + the product thinker, who reads as the researcher here, for the value and the + grounds, under a `Product` chip; the reading names the tension and never marks the condition itself. A condition's standing is its latest reading-occasioned block where it has one, diff --git a/commands/mode.md b/commands/mode.md index d3e16e9c9..bb823fdbd 100644 --- a/commands/mode.md +++ b/commands/mode.md @@ -27,21 +27,27 @@ Then tell the user the `state`. An absent store reads as `managed`; outside any ## Set the state — two writers, one verb -**The agent, at a stop.** When you stop to obtain a verdict, record whom you -are addressing *before* you stop, so the parked stop is visible on the status -line and the board while you wait: +**The agent, at a question.** A question through the host's question tool +needs no setting: its chip already names whom it is for. When the guard admits +abcd's question in a repository abcd manages, it sets the state from the chip's +role (`Product` names the product thinker; `Tech` and `Setup` name the +technical facilitator), so a chipped question is never refused on the mode, +and it marks the question open. The answer resets the state to `managed`: the +plugin's hook on the question tool's result resets it when the answer comes +back, and on a host that does not run that hook the prompt hook resets it on +the next human message. Either says so once on stderr, so you do not set it +back yourself. + +**The agent, at a stop that is not a question.** When you stop to obtain a +verdict any other way, such as a hand-off or a step the person runs, record +whom you are addressing *before* you stop, so the parked stop is visible on +the status line and the board while you wait: ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" mode product-thinker --json ``` -Use `facilitator` when the verdict is the technical facilitator's to give. -The guard holds you to this: abcd's question through the host's question tool -(one headed with abcd's chip, such as `Product Q1`) is refused while the state -reads `managed`, and the refusal names the two settings. Choose the product thinker or the technical facilitator, set it, -then ask. An admitted question is marked open, and the next human message is -its answer: the prompt hook resets the state to `managed` and says so once on -stderr, so you do not set it back yourself. At a stop with no question tool, +Use `facilitator` when the verdict is the technical facilitator's to give, and set it back when the loop resumes: ```bash diff --git a/commands/prepare-this-repo.md b/commands/prepare-this-repo.md index 740301383..0fad72d5c 100644 --- a/commands/prepare-this-repo.md +++ b/commands/prepare-this-repo.md @@ -162,8 +162,8 @@ target's `.abcd/.work.local/scratch/` (create the directory via Re-interviewing over an answer the repo already gives is how a project ends up with two canons. - **Only if there is no block**, set `abcd mode product-thinker`, then ask the - product thinker once, in their own words: + **Only if there is no block**, ask the product thinker once, under a + `Product` chip, in their own words: - **Title** (required) — what the project is called, as it should read in a heading. diff --git a/commands/reflect.md b/commands/reflect.md index d66660ede..895fcc2ab 100644 --- a/commands/reflect.md +++ b/commands/reflect.md @@ -36,8 +36,8 @@ and an intent id exit 2. ## 2. Before the interview -Set `abcd mode product-thinker` before the first question: the retrospective -is the product thinker's to answer, and the mode says whose answer the loop +The retrospective is the product thinker's to answer, so every question is +headed with a `Product` chip, which sets the mode to say whose answer the loop waits on. - **Intents without audit notes** (`unaudited[]`): name each one to the product thinker @@ -51,7 +51,7 @@ waits on. ## 3. The interview -With `abcd mode product-thinker` set, dispatch the `reflection-composer` agent with the seed. It asks the four asked +Dispatch the `reflection-composer` agent with the seed. It asks the four asked sections (what went well, what could improve, lessons learned, decisions made) **one question at a time**, through the host's interactive question tool, the next question only after the last answer. Every question it asks follows the @@ -95,9 +95,8 @@ notes. It refuses, exiting 1 and writing nothing, with a structured refusal: - `refused: "thin_answers"` — `thin[]` names each thin section and the follow-up question to ask. Ask it, add the reply as that section's `follow_up`, and write again. -- `refused: "unshipped_targets"` — the product thinker has not confirmed; with - `abcd mode product-thinker` set, ask, and write - again with `--proceed` on a yes. +- `refused: "unshipped_targets"` — the product thinker has not confirmed; ask + them under a `Product` chip, and write again with `--proceed` on a yes. - `refused: "exists"` / `"nothing_shipped"` — relay and stop. A malformed answers file (an unknown or repeated key) exits 2. diff --git a/commands/site.md b/commands/site.md index c31eac0ff..c902566ee 100644 --- a/commands/site.md +++ b/commands/site.md @@ -139,8 +139,8 @@ sets up the site of a repository abcd manages, in three stages, and emits Both remote stages ask before they write, naming each change. An unanswered run declines them and exits `1`; `--yes` confirms in advance — pass it only -when the technical facilitator has asked for the forge and host changes, and set -`abcd mode facilitator` before asking them. A second run over an +when the technical facilitator has asked for the forge and host changes, in a +question headed with a `Tech` chip. A second run over an unchanged repository reports `no_change` and writes nothing. The first run names the host after the repository; `--name` and `--domain` diff --git a/commands/source.md b/commands/source.md index 2196e6262..f82bf38d7 100644 --- a/commands/source.md +++ b/commands/source.md @@ -41,9 +41,9 @@ there is no corpus: say so, and stop. Only the user decides to create one, with [--permission ] [--ban-authors] [--text ] --json ``` -- The class is required and never defaulted. When in doubt, set `abcd mode - product-thinker` or `abcd mode facilitator` for whichever of the product - thinker or the technical facilitator is adding the source, then ask them. +- The class is required and never defaulted. When in doubt, ask whichever of the + product thinker or the technical facilitator is adding the source, under the + chip naming their role. - For a confidential source, put the title, aliases and authors in a `--meta` JSON file (`{"title": …, "aliases": […], "author": [{"family": …, "given": …}], "keywords": […]}`) so they stay out of argv, and choose an **opaque** key diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index f2ce189ca..c1887a018 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1373,12 +1373,13 @@ limit and the remedy. The rows limit is the exception: a question whose only finding is its height is shown, and the hook's stdout carries a note for the agent (additionalContext, no permission decision) naming each tab over the limit and the agent that drafts a question to fit. The hook never -rewrites a question. In a checkout abcd manages, -abcd's question asked while `abcd mode` reads managed is also refused, -naming `abcd mode product-thinker` and `abcd mode facilitator`; once the -mode names somebody the question runs and is marked open in the local tier, -and the next human message resets the mode to managed. A questions field -the hook cannot read lets the question run and warns loudly. +rewrites a question. In a checkout abcd manages, an admitted question +carrying abcd's chip sets `abcd mode` from the chip's role (Product to +product-thinker, Tech and Setup to facilitator), so it is never refused on +the mode, and the question is marked open in the local tier; the answer +resets the mode to managed, from `abcd hook question-answered` on the +question tool's PostToolUse or else on the next human message. A questions +field the hook cannot read lets the question run and warns loudly. ### `abcd help` @@ -3022,18 +3023,20 @@ on the product thinker, who answers on a surface of their own). An absent store reads as `managed`. `abcd mode ` sets it. Two writers share the verb: the agent runs it -when it stops for a verdict, naming whom it is addressing, and the human runs -it by hand to say which hat they wear. The state lives per checkout at +when it stops for a verdict other than a question, naming whom it is +addressing, and the human runs it by hand to say which hat they wear. The state lives per checkout at `.abcd/.work.local/mode`, so only a repository abcd manages — one that has the local-ephemeral tier — can hold it; elsewhere the set refuses and creates nothing. The next status-line refresh and the bare `abcd` board read the same file. -The guard holds the agent to it: a question through the host's question -tool is refused while the state reads `managed`, and once the state names -somebody the question runs and is marked open. The next human message is -its answer, so the prompt hook resets the state to `managed` and says so -once on stderr. A state set by hand with no question open is left as it is. +A question needs no setting: when the guard admits abcd's question through +the host's question tool, it sets the state from the question's chip +(Product to product-thinker, Tech and Setup to facilitator) and marks the +question open. The answer resets the state to `managed` and says so once on +stderr: the question tool's PostToolUse hook resets it when the answer comes +back, and the prompt hook on the next human message where the host runs no +PostToolUse. A state set by hand with no question open is left as it is. Where this machine has no status surface — no `~/.abcd.noindex/statusline.json`, or one with `disabled` set — the set form prints one line naming whose answer diff --git a/hooks/hooks.json b/hooks/hooks.json index 7025c7159..406c3b00e 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -36,6 +36,18 @@ ] } ], + "PostToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.sh\"" + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/hooks/post-tool-use.sh b/hooks/post-tool-use.sh new file mode 100644 index 000000000..40977e71b --- /dev/null +++ b/hooks/post-tool-use.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# abcd's PostToolUse hook, on the question tool. hooks/hooks.json runs this +# file with sh, so the host's hook-error line names this short command rather +# than the whole script. +r="${CLAUDE_PLUGIN_ROOT:-}"; [ -n "$r" ] || exit 0; z=""; case "${HOME:-}" in /*) if [ -e "$HOME/.abcd" ] || [ -L "$HOME/.abcd" ]; then z=1; fi ;; esac; if [ -n "$z" ]; then if [ -f "$r/abcd" ] && [ -x "$r/abcd" ]; then exec "$r/abcd" hook question-answered; fi; if [ -e "$HOME/.abcd.noindex" ] || [ -L "$HOME/.abcd.noindex" ]; then printf '%s\n' 'abcd: Both ~/.abcd and ~/.abcd.noindex exist, so abcd has written nothing and moves neither. Keep the one you want, named ~/.abcd.noindex, and take the other out of your home folder, reconnect the working copies kept there with `find ~/.abcd.noindex/worktrees -type d -exec test -e {}/.git \; -prune -exec test -f {}/.git \; -exec git -C {} worktree repair \;` (each `repair: gitdir incorrect` line it prints is a link it fixed), then run abcd again.' >&2; else printf '%s\n' 'abcd: abcd'\''s folder is now ~/.abcd.noindex, a name the Mac'\''s search indexer passes over, and ~/.abcd still stands, so abcd has written nothing. Rename it with `mv ~/.abcd ~/.abcd.noindex`, reconnect the working copies kept there with `find ~/.abcd.noindex/worktrees -type d -exec test -e {}/.git \; -prune -exec test -f {}/.git \; -exec git -C {} worktree repair \;` (each `repair: gitdir incorrect` line it prints is a link it fixed), then run abcd again.' >&2; fi; exit 1; fi; if [ ! -x "$r/abcd" ] && [ -x "$r/hooks/bootstrap.sh" ] && [ -z "$(find "$r/.bootstrap.attempt" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then : > "$r/.bootstrap.attempt" 2>/dev/null || true; "$r/hooks/bootstrap.sh" --unseen >/dev/null 2>&1 /dev/null); if [ -n "$c" ]; then y=""; case "$c" in /*) dd=${c%/*}; [ -n "$dd" ] || dd=/; d=$(cd -P "$dd" 2>/dev/null && pwd -P); if [ -z "$d" ]; then y="its directory could not be resolved"; else q=$(pwd -P); case "$d/" in "$q"/*) y="it lives inside the working tree" ;; esac; fi; if [ -z "$y" ]; then case "$(/bin/ls -ld "$d" 2>/dev/null)" in ????????w*) y="its directory is world-writable" ;; esac; fi; if [ -z "$y" ]; then case "$(/bin/ls -ldL "$c" 2>/dev/null)" in ????????w*) y="the binary itself is world-writable" ;; esac; fi ;; *) y="it did not resolve to an absolute path" ;; esac; if [ -z "$y" ]; then o=""; w=""; e="${HOME:-}/.abcd.noindex/path-entry"; if [ -n "${HOME:-}" ] && [ -f "$e" ]; then if [ -L "${HOME}/.abcd.noindex" ]; then w=2; elif [ -n "$(find "$e" -maxdepth 0 -type f -user "$(id -un 2>/dev/null)" ! -perm -0020 ! -perm -0002 2>/dev/null)" ]; then while IFS= read -r ln || [ -n "$ln" ]; do case "$ln" in path=*) if [ "${ln#path=}" = "$c" ]; then o=1; fi ;; esac; done < "$e"; else w=1; fi; fi; if [ "$w" = 2 ]; then y="~/.abcd.noindex is a symlink, so its path-entry record is not read (replace the link with a real directory)"; elif [ -n "$w" ]; then y="its ~/.abcd.noindex/path-entry record is not owned by you or is writable by others"; else [ -n "$o" ] || y="~/.abcd.noindex/path-entry does not record it as the abcd installed here"; fi; fi; if [ -n "$y" ]; then p=$(printf '%s' "$c" | tr -d '\000-\037\177'); printf '%s\n' "abcd: ignoring the abcd found on PATH at $p because $y — a hook runs only the abcd recorded in ~/.abcd.noindex/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it" >&2; else g="$c"; fi; fi; fi; if [ -n "$g" ]; then exec "$g" hook question-answered; fi; printf '%s\n' "abcd: the plugin binary is missing, so the mode resets on the next message rather than when the answer comes back — install per https://github.com/intentdriven/abcd#install" >&2; exit 1 diff --git a/internal/core/mode/question.go b/internal/core/mode/question.go index 299af08ac..213932327 100644 --- a/internal/core/mode/question.go +++ b/internal/core/mode/question.go @@ -3,13 +3,14 @@ package mode // The question marker: how the badge stays true across the two moments it must // change (itd-2609212130146198). // -// The agent sets the state with the verb when it stops to ask, naming whom it -// addresses; the guard admits a question on the host's question tool only once -// that state names somebody, and when it admits one it writes this marker. The -// next human message is the answer, and the prompt hook that sees it resets the -// state to Managed and clears the marker. Without the marker the prompt hook -// changes nothing, so a state the human set by hand to say which hat they wear -// is not clobbered by the message they type next. +// When the guard admits abcd's question on the host's question tool, it writes +// this marker and sets the state from the question's chip, naming whom the +// question is for (iss-2610100626211810). The answer resets the state to +// Managed and clears the marker: the question tool's PostToolUse hook runs the +// reset when the answer comes back, and the prompt hook runs it on the next +// human message, for a host that does not run PostToolUse. Without the marker +// neither hook changes anything, so a state the human set by hand to say which +// hat they wear is not clobbered by the message they type next. // // The marker lives beside the store, in the same local-ephemeral tier, and // follows the store's rules: it is written only where the tier already is, the @@ -17,8 +18,6 @@ package mode // through an os.Root so a symlinked ancestor cannot walk it out of the checkout. import ( - "crypto/rand" - "encoding/hex" "errors" "fmt" "io/fs" @@ -35,7 +34,6 @@ const QuestionOpenRelPath = TierRelPath + "/question_open" // HasTier reports whether repoRoot holds the local-ephemeral tier as a real // directory — the same presence test SetAt applies before it writes. It says // the tier is HERE, not that it can be written: a read-only tier passes it. -// A caller that needs "the verb can set the state here" asks CanSet. func HasTier(repoRoot string) bool { root, err := os.OpenRoot(repoRoot) if err != nil { @@ -96,7 +94,7 @@ func markerIn(root *os.Root) (bool, error) { return false, fmt.Errorf("reading the question marker at %s: %w", QuestionOpenRelPath, err) } -// ResetOnAnswer is the reset the next human message triggers: when a question +// ResetOnAnswer is the reset the answer triggers: when a question // is marked open it records Managed, then clears the marker, and reports true. // With no question open it changes nothing and reports false. // @@ -134,105 +132,3 @@ func ResetOnAnswer(repoRoot string) (bool, error) { } return true, nil } - -// probePrefix names the probe file CanSet creates and removes. It sits beside -// the atomic writer's own temp names in the tier and is never left behind. -const probePrefix = ".mode-probe-" - -// CanSet reports whether SetAt could record a state in repoRoot right now, and -// why not when it could not. It is the question gate's check before it names -// `abcd mode` as the remedy for a refused question: a refusal whose remedy -// cannot run refuses forever (iss-2609260100382261). -// -// It probes what the write needs rather than what the permission bits say: -// the tier is a real directory (ErrNoLocalTier otherwise, as SetAt refuses), -// nothing stands at the store's path that the writer's rename cannot replace, -// and a file can be created in the tier — the atomic writer's first step. The -// probe file is removed before CanSet returns, so a successful probe leaves the -// tier as it found it, and a failed create leaves nothing to remove. Creating -// is the test because it is what fails on a read-only mount, an unwritable -// directory and a foreign owner alike, where a mode-bit check answers only the -// second. -// -// A probe the remove could not reach — the tier turned unwritable between the -// create and the remove, or the process died between them — would otherwise -// stay in the tier for good, so every call first sweeps the probes an earlier -// call left behind (iss-2609261403493536). The sweep removes only regular files -// named exactly as a probe is named, and it is best-effort: a tier it cannot -// read or clear is the one the create below then reports. Because a concurrent -// call's sweep can take this call's probe, a probe already gone when this call -// removes it is not a fault: its create succeeded, which is what was asked. -func CanSet(repoRoot string) error { - root, err := os.OpenRoot(repoRoot) - if err != nil { - return fmt.Errorf("opening the checkout to probe %s: %w", StoreName, err) - } - defer root.Close() - if !tierIn(root) { - return fmt.Errorf("%w: %s/ is not a directory in this checkout, so there is nowhere for %s to live", - ErrNoLocalTier, TierRelPath, StoreName) - } - sweepProbes(root) - if fi, err := root.Lstat(FileRelPath); err == nil && fi.IsDir() { - return fmt.Errorf("%s at %s is a directory, which the writer cannot replace", StoreName, FileRelPath) - } else if err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("inspecting %s at %s: %w", StoreName, FileRelPath, err) - } - var buf [8]byte - if _, err := rand.Read(buf[:]); err != nil { - return fmt.Errorf("naming the probe for %s: %w", StoreName, err) - } - probe := TierRelPath + "/" + probePrefix + hex.EncodeToString(buf[:]) - f, err := root.OpenFile(probe, os.O_WRONLY|os.O_CREATE|os.O_EXCL, storePerm) - if err != nil { - return fmt.Errorf("%s/ is not writable, so %s cannot be recorded here: %w", TierRelPath, StoreName, err) - } - closeErr := f.Close() - if err := root.Remove(probe); err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("removing the probe %s: %w", probe, err) - } - if closeErr != nil { - return fmt.Errorf("closing the probe %s: %w", probe, closeErr) - } - return nil -} - -// probeNameLen is the length of a probe's name: the prefix and the hex of its -// eight random bytes. -const probeNameLen = len(probePrefix) + 16 - -// isProbeName reports whether name is exactly the shape CanSet gives a probe, -// so the sweep never takes a file that merely shares the prefix. -func isProbeName(name string) bool { - if len(name) != probeNameLen || name[:len(probePrefix)] != probePrefix { - return false - } - for _, c := range name[len(probePrefix):] { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return false - } - } - return true -} - -// sweepProbes removes the probe files an earlier CanSet left in the tier. It -// reports nothing: a tier it cannot list or clear is one CanSet's own create -// then fails on and names, and a probe that is no longer a regular file is -// nothing CanSet wrote. -func sweepProbes(root *os.Root) { - dir, err := root.Open(TierRelPath) - if err != nil { - return - } - ents, err := dir.ReadDir(-1) - _ = dir.Close() - if err != nil { - return - } - for _, e := range ents { - if !e.Type().IsRegular() || !isProbeName(e.Name()) { - continue - } - _ = root.Remove(TierRelPath + "/" + e.Name()) - } -} diff --git a/internal/core/mode/question_test.go b/internal/core/mode/question_test.go index 24ab0853f..50669daf3 100644 --- a/internal/core/mode/question_test.go +++ b/internal/core/mode/question_test.go @@ -118,121 +118,6 @@ func TestResetOnAnswerOutsideAManagedTree(t *testing.T) { } } -// tierEntries lists the tier's names, for proving a probe leaves no residue. -func tierEntries(t *testing.T, tier string) []string { - t.Helper() - ents, err := os.ReadDir(tier) - if err != nil { - t.Fatal(err) - } - names := make([]string, 0, len(ents)) - for _, e := range ents { - names = append(names, e.Name()) - } - return names -} - -// TestCanSetProbesWhatTheVerbNeeds (iss-2609260100382261): the gate names -// `abcd mode` as the remedy for a refused question, so it must know the verb -// could actually run here. CanSet answers exactly that — the tier is a real, -// writable directory and nothing stands at the store's path that the verb's -// rename could not replace — and it leaves nothing behind in the tier either -// way. -func TestCanSetProbesWhatTheVerbNeeds(t *testing.T) { - root := newRepo(t) - if err := mode.CanSet(root); !errors.Is(err, mode.ErrNoLocalTier) { - t.Fatalf("CanSet without the tier = %v, want ErrNoLocalTier", err) - } - - tier := makeTier(t, root) - if err := mode.CanSet(root); err != nil { - t.Fatalf("CanSet on a writable tier = %v, want nil", err) - } - if got := tierEntries(t, tier); len(got) != 0 { - t.Fatalf("the probe left residue in the tier: %v", got) - } - - if os.Geteuid() == 0 { - t.Log("running as root: a read-only directory does not refuse root, so the unwritable leg is skipped") - } else { - if err := os.Chmod(tier, 0o500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(tier, 0o700) }) - if err := mode.CanSet(root); err == nil { - t.Fatal("CanSet on a read-only tier = nil; the verb cannot write there") - } - if err := mode.SetAt(root, mode.Facilitator); err == nil { - t.Fatal("precondition: SetAt succeeded on a read-only tier, so the probe's premise is wrong") - } - if err := os.Chmod(tier, 0o700); err != nil { - t.Fatal(err) - } - if got := tierEntries(t, tier); len(got) != 0 { - t.Fatalf("a failed probe left residue in the tier: %v", got) - } - } - - if err := os.Mkdir(storePath(root), 0o700); err != nil { - t.Fatal(err) - } - if err := mode.CanSet(root); err == nil { - t.Fatal("CanSet with a directory at the store's path = nil; the verb's rename cannot replace it") - } -} - -// TestCanSetSweepsTheProbesAnEarlierCallLeftBehind (iss-2609261403493536): a -// probe created while the tier was writable and removed after it turned -// unwritable (or orphaned by a kill between the two) stays in the tier, and -// nothing else ever removes it. The next CanSet sweeps every such probe, and -// only those: a file that merely starts with the prefix, or a directory -// wearing a probe's name, is nothing CanSet wrote and is left alone. -func TestCanSetSweepsTheProbesAnEarlierCallLeftBehind(t *testing.T) { - root := newRepo(t) - tier := makeTier(t, root) - for _, name := range []string{".mode-probe-0123456789abcdef", ".mode-probe-fedcba9876543210"} { - if err := os.WriteFile(filepath.Join(tier, name), nil, 0o600); err != nil { - t.Fatal(err) - } - } - keep := []string{".mode-probe-notes.txt", ".mode-probe-00112233445566aa"} - if err := os.WriteFile(filepath.Join(tier, keep[0]), []byte("mine"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(tier, keep[1]), 0o700); err != nil { - t.Fatal(err) - } - - if err := mode.CanSet(root); err != nil { - t.Fatalf("CanSet on a writable tier = %v, want nil", err) - } - got := tierEntries(t, tier) - if len(got) != len(keep) || got[0] != keep[1] || got[1] != keep[0] { - t.Fatalf("tier after CanSet = %v, want only %v (the stale probes swept, nothing else touched)", got, keep) - } -} - -// TestCanSetConcurrentProbesAllAnswer: the sweep must not turn one caller's -// in-flight probe into another caller's fault. Concurrent probes on a writable -// tier all answer nil and leave the tier empty. -func TestCanSetConcurrentProbesAllAnswer(t *testing.T) { - root := newRepo(t) - tier := makeTier(t, root) - const n = 32 - errs := make(chan error, n) - for range n { - go func() { errs <- mode.CanSet(root) }() - } - for range n { - if err := <-errs; err != nil { - t.Errorf("concurrent CanSet = %v, want nil", err) - } - } - if got := tierEntries(t, tier); len(got) != 0 { - t.Fatalf("concurrent probes left residue in the tier: %v", got) - } -} - // TestResetOnAnswerRefusesAMarkerItCannotRemove (iss-2609260100393814): a // directory planted at the marker's path is nothing the gate wrote and nothing // the reset can clear, so resetting on it would reset a hand-set mode on every diff --git a/internal/core/question/asking.go b/internal/core/question/asking.go index bbf8bfeb5..ce1d6eaf2 100644 --- a/internal/core/question/asking.go +++ b/internal/core/question/asking.go @@ -83,10 +83,10 @@ func AskingRules(l Limits) []string { "What each person can be assumed to know, the knowledge floor an explanation is measured against, is stated in full at " + KnowledgeFloorPage + " under abcd's plugin root (in abcd's own repository, at that path from its root).", - "In abcd's own interviews, before stopping for an answer, record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. " + + "In abcd's own interviews, before a stop that is not a question (a hand-off, or a step the person runs), record whose answer is owed with `abcd mode facilitator` or `abcd mode product-thinker`, and set it back with `abcd mode managed` once the answer is in. " + "Where the host has no status surface, the set form prints one line naming the addressee; relay it verbatim, because that line is the whole of the fallback.", - "In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator); when it differs from the current mode, set the mode, then ask, and the chip names that role. " + + "In abcd's own interviews, classify each question's addressee first (the product thinker or the technical facilitator), and head the question with the chip naming that role: the chip sets the mode when the question is asked, and the answer sets it back to managed, so a question needs no `abcd mode` before it. " + "The status line names the person the question on screen is for, so a mixed interview re-sets the mode per question, never once at the start.", } } diff --git a/internal/core/rules/grill_test.go b/internal/core/rules/grill_test.go index a0bf7aa46..6d0ef412c 100644 --- a/internal/core/rules/grill_test.go +++ b/internal/core/rules/grill_test.go @@ -169,7 +169,8 @@ func TestGrillTextScopesRegisterToAbcdInterviews(t *testing.T) { }{ {"register", []string{"in their register", "outcomes and choices in product terms", "the mechanism, the ids, and the trade-offs"}}, {"role first", []string{"which role the person holds", "the first question asks that"}}, - {"mode verb", []string{"`abcd mode facilitator`", "`abcd mode product-thinker`", "`abcd mode managed`", "relay it verbatim"}}, + {"mode verb", []string{"`abcd mode facilitator`", "`abcd mode product-thinker`", "`abcd mode managed`", "relay it verbatim", "a stop that is not a question"}}, + {"chip sets the mode", []string{"the chip sets the mode", "the answer sets it back to managed", "needs no `abcd mode` before it"}}, {"addressee first", []string{"classify each question's addressee first", "a mixed interview re-sets the mode per question"}}, } { r := ruleWith(rules, c.phrases...) diff --git a/internal/reachaudit/testdata/core-unreached.txt b/internal/reachaudit/testdata/core-unreached.txt index 204b771cf..e88c05bfb 100644 --- a/internal/reachaudit/testdata/core-unreached.txt +++ b/internal/reachaudit/testdata/core-unreached.txt @@ -92,7 +92,6 @@ internal/core/memory.SourceHashes internal/core/memory.SourcesIndexPath internal/core/memory.WithStoreLock internal/core/memory.WritePages -internal/core/mode.SetAt internal/core/oracle.Ceiling internal/core/oracle.Denied internal/core/oracle.ParseTier diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 01c274ce1..aa1bf7cd2 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -151,7 +151,7 @@ func applyHookPlaneFailOpen(root *cobra.Command) { {"guard"}, {"guard", "hook"}, {"hook"}, {"hook", "prompt-router"}, {"hook", "prompt-router-reset"}, {"hook", "session-start"}, {"hook", "session-end"}, - {"hook", "subagent-stop"}, + {"hook", "subagent-stop"}, {"hook", "question-answered"}, } { if cmd := findByPath(root, path); cmd != nil { cmd.SetFlagErrorFunc(failOpenFlagError) @@ -1523,6 +1523,9 @@ type hookInput struct { Prompt string `json:"prompt"` Source string `json:"source"` Event string `json:"hook_event_name"` + // ToolName is supplied by the PostToolUse hook; read by `hook + // question-answered` only. + ToolName string `json:"tool_name"` // TranscriptPath is supplied by the Stop hook; it names the session // transcript on disk. Read by `hook session-end` only. TranscriptPath string `json:"transcript_path"` @@ -1762,6 +1765,37 @@ an error included, exits 0, so the hook can never wedge a session.`, }, }) + // question-answered — PostToolUse on the question tool: the answer has come + // back, so the badge's reset runs now rather than on the next prompt + // (iss-2610100626211810). The prompt router runs the same reset as the + // fallback for a host that does not run PostToolUse; whichever runs first + // clears the marker, so the other changes nothing. Fail-open like every + // hook verb: an unreadable payload is named on stderr and exits 0, and + // nothing goes to stdout, which the host hands the agent. + hookCmd.AddCommand(&cobra.Command{ + Use: "question-answered", + Short: "PostToolUse on the question tool: reset the mode once the answer is back", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + in, err := readHookInput(cmd) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd mode: unreadable hook payload (%s); the mode resets on the next message instead\n", termsafe.Sanitize(err.Error())) + return nil + } + if !isQuestionTool(in.ToolName) { + return nil + } + cwd := in.Cwd + if cwd == "" { + if wd, err := os.Getwd(); err == nil { + cwd = wd + } + } + resetModeOnAnswer(cmd.ErrOrStderr(), cwd) + return nil + }, + }) + // session-end — SessionEnd: redact and store the session transcript (adr-29). // // Wired to SessionEnd, NOT Stop. The plan said Stop, but Stop fires once per diff --git a/internal/surface/cli/guard.go b/internal/surface/cli/guard.go index 92507b348..e7750b182 100644 --- a/internal/surface/cli/guard.go +++ b/internal/surface/cli/guard.go @@ -267,12 +267,13 @@ func newGuardHookCommand() *cobra.Command { "only finding is its height is shown, and the hook's stdout carries a note\n" + "for the agent (additionalContext, no permission decision) naming each tab\n" + "over the limit and the agent that drafts a question to fit. The hook never\n" + - "rewrites a question. In a checkout abcd manages,\n" + - "abcd's question asked while `abcd mode` reads managed is also refused,\n" + - "naming `abcd mode product-thinker` and `abcd mode facilitator`; once the\n" + - "mode names somebody the question runs and is marked open in the local tier,\n" + - "and the next human message resets the mode to managed. A questions field\n" + - "the hook cannot read lets the question run and warns loudly.", + "rewrites a question. In a checkout abcd manages, an admitted question\n" + + "carrying abcd's chip sets `abcd mode` from the chip's role (Product to\n" + + "product-thinker, Tech and Setup to facilitator), so it is never refused on\n" + + "the mode, and the question is marked open in the local tier; the answer\n" + + "resets the mode to managed, from `abcd hook question-answered` on the\n" + + "question tool's PostToolUse or else on the next human message. A questions\n" + + "field the hook cannot read lets the question run and warns loudly.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return answerGuardHook(cmd, guardHookDecide) diff --git a/internal/surface/cli/guard_question.go b/internal/surface/cli/guard_question.go index 80bea116a..5641b5c2a 100644 --- a/internal/surface/cli/guard_question.go +++ b/internal/surface/cli/guard_question.go @@ -15,17 +15,18 @@ import ( ) // The question check, the question gate and its reset (itd-2609212130146198; -// spc-2610030944505997). +// spc-2610030944505997; iss-2610100626211810). // // The guard hook, on the host's question tool, holds abcd's own questions to // the field limits of internal/core/question wherever it runs, and refuses a // badly built one naming each part to fix. In a checkout abcd manages it also -// refuses abcd's question while the mode reads managed — the agent has not -// said whom it is asking, so the badge would read "nobody is waiting" while -// somebody is — and marks an admitted question open. The prompt hook, on the -// next human message, resets the mode to managed when a question is marked -// open, because that message is the answer. Another tool's question is none of -// this file's business and runs unchecked. +// keeps the status-line badge true: abcd's question carries a chip naming +// whom it is for, so an admitted question sets the mode from the chip's role +// and is marked open. The answer resets the mode to managed: the question +// tool's PostToolUse hook runs the reset when the answer comes back, and the +// prompt hook runs the same reset on the next human message, for a host that +// does not run PostToolUse. Another tool's question is none of this file's +// business and runs unchecked. // // This file is the whole of the feature on the front-door side. The guard's // own hook calls questionGate once, before its shell-command path, and touches @@ -33,7 +34,8 @@ import ( // questionTools are the host tool names that put a question to the human. The // hook manifest's PreToolUse matcher names exactly these beside the shell tool -// (TestGuardHookIsInstalledForBashCalls holds the two together). +// (TestGuardHookIsInstalledForBashCalls holds the two together), and its +// PostToolUse matcher names exactly these alone (TestQuestionAnswerHookIsInstalled). var questionTools = []string{"AskUserQuestion"} // isQuestionTool reports whether a hook payload's tool is a question tool. @@ -46,14 +48,6 @@ func isQuestionTool(name string) bool { return false } -// questionRefusal is the one line the host replays to the agent when it asks -// while the mode reads managed. It names the two settings and the verb, and -// reminds the agent that choosing is its job. -const questionRefusal = "Blocked by the abcd guard (question tool): the mode reads managed, so the status line says nobody is waiting. " + - "Before asking, say whom the question is for: run `abcd mode product-thinker` if it is for the product thinker, " + - "or `abcd mode facilitator` if it is for the technical facilitator, then ask again. " + - "The mode resets to managed on the next human message." - // questionGate is the guard hook's answer for a question-tool call // (itd-2609212130146198; spc-2610030944505997, "The question check in the // guard hook"). It never rewrites the question: it admits it or refuses it @@ -65,22 +59,22 @@ const questionRefusal = "Blocked by the abcd guard (question tool): the mode rea // cannot read is not a decision, so the question runs on the loud, non-blocking // status. Then the gate decides whether the question is abcd's: a header in // abcd's chip grammar, which only abcd's interview pages are taught to write, -// or a mode naming somebody, which only abcd's mode verb sets (itd-201 decision -// 10). Anything else is another tool's question and runs, unchecked and -// unmarked, wherever it is asked. +// or a mode naming somebody, which only abcd's mode verb and this gate set +// (itd-201 decision 10). Anything else is another tool's question and runs, +// unchecked and unmarked, wherever it is asked. // // abcd's question is held to the field limits wherever the hook runs, managed // or not: the setup interview asks before a repository is managed, and the -// limits need no store. Where the badge shows (a checkout abcd manages, with the -// local tier the mode verb writes to), the mode gate runs as well: an abcd -// question while the mode reads managed is refused, naming `abcd mode`, and an -// admitted one is marked open, so the next human message resets the mode. A -// store or marker the gate cannot read or write is not a decision: the question -// runs and the gate says so, the guard's fail-open-loud contract. A tier the -// verb cannot write is the same case seen from the refusal's side: a remedy that -// cannot run would hold the question refused forever, so there the mode gate -// stands down, loudly, and only the field findings, whose remedy is the -// agent's own, can refuse. +// limits need no store. A chipped question is checked against the addressee +// each tab's chip names, and a question without a chip against the person the +// mode names. Only the field findings refuse: a chipped question is never +// refused on the mode (iss-2610100626211810), because its chip already says +// whom it is for. Where the badge shows (a checkout abcd manages, with the +// local tier the mode verb writes to), an admitted question is marked open and +// a chipped one sets the mode from its chip (chipState), so the status line +// names whom the question on screen is for. A store or marker the gate cannot +// read or write is not a decision: the question runs and the gate says so, the +// guard's fail-open-loud contract. func questionGate(cmd *cobra.Command, cwd string, raw json.RawMessage) error { stderr := cmd.ErrOrStderr() fields, err := decodeQuestions(raw) @@ -103,45 +97,45 @@ func questionGate(cmd *cobra.Command, cwd string, raw json.RawMessage) error { return nil } + // A chip names the addressee of its own tab, and the mode the gate sets + // follows the chip, so a chipped question is checked against its chips + // and not against a mode it is about to replace. + who := addresseeOf(st) + if chip { + who = question.Unnamed + } findings := question.CheckLimits(fields, question.Default, question.Addressee{ - Person: addresseeOf(st), + Person: who, Verbs: verbsOf(cmd.Root()), }) // The rows limit alone does not refuse (iss-2610070637562567): a question // too tall for the narrow window is shown, and the agent is told // afterwards. Refusing it made the agent redraft a question the person // was ready to answer. - refuses := len(findings) > 0 && !rowsOnly(findings) - modeRefuses := false - if badge && st == mode.Managed { - // A refusal whose remedy cannot run would refuse this question - // forever (iss-2609260100382261), so the gate refuses on the mode - // only where the verb it names could set the state. - if err := mode.CanSet(root); err != nil { - if !refuses { - return questionFailOpen(stderr, "the mode reads managed but cannot be set here, so `abcd mode` could not answer a refusal (%s)", err) - } - } else { - modeRefuses = true + if len(findings) > 0 && !rowsOnly(findings) { + why := "" + if !chip { + why = modeMadeAbcds(st) } - } - if refuses || modeRefuses { var reason strings.Builder - if len(findings) > 0 { - why := "" - if !chip { - why = modeMadeAbcds(st) - } - writeLimitsRefusal(&reason, findings, why) - } - if modeRefuses { - fmt.Fprintln(&reason, questionRefusal) - } + writeLimitsRefusal(&reason, findings, why) return denyCall(reason.String()) } if badge { - if err := mode.MarkQuestionOpen(root, st); err != nil { - return questionFailOpen(stderr, "the question could not be marked open, so the mode will not reset on the answer (%s)", err) + want := st + if chip { + want = chipState(fields) + } + // The marker first: a marker written for a mode that then could not + // be set costs one reset to managed on the answer, while a mode set + // with no marker would stay parked with nothing left to reset it. + if err := mode.MarkQuestionOpen(root, want); err != nil { + return questionFailOpen(stderr, "the question could not be marked open, so the mode was not set from its chip and will not reset on the answer (%s)", err) + } + if want != st { + if err := mode.SetAt(root, want); err != nil { + return questionFailOpen(stderr, "the mode could not be set from the question's chip, so the status line does not say whom the question is for (%s)", err) + } } } if len(findings) > 0 { @@ -155,6 +149,35 @@ func questionGate(cmd *cobra.Command, cwd string, raw json.RawMessage) error { return nil } +// chipState is the mode a chipped question sets (iss-2610100626211810): the +// person its chips name. The Product chip names the product thinker, and the +// Tech chip the technical facilitator. The Setup chip names the facilitator +// too: the setup interview asks how abcd is installed and configured in a +// repository (its visibility, its keys, the oracles it connects), which is +// the how of the work, the facilitator's half, never the what. A call whose +// tabs name both people sets the product thinker, so the status line never +// says only the facilitator is owed an answer the product thinker owes too. +func chipState(f question.Fields) mode.State { + st := mode.Facilitator + for _, t := range f.Tabs { + if role, ok := question.ChipRole(t.Header, question.Default); ok && chipRoleState(role) == mode.ProductThinker { + st = mode.ProductThinker + } + } + return st +} + +// chipRoleState is the mode one chip role word names: the product thinker +// for the Product role, the technical facilitator for every other role (Tech +// and Setup). The interview pages' test reads it to accept a page that names +// a question's chip in place of setting the mode. +func chipRoleState(role string) mode.State { + if role == question.ProductRole { + return mode.ProductThinker + } + return mode.Facilitator +} + // rowsOnly reports whether every finding is the rows limit's. func rowsOnly(findings []question.Finding) bool { for _, f := range findings { @@ -282,25 +305,55 @@ func modeMadeAbcds(st mode.State) string { "; another tool's question asked now is held to abcd's rules." } -// writeLimitsRefusal writes the refusal the host's deny carries: one -// head line counting every part, then, when why is set, the one line saying -// why a question without abcd's chip is abcd's, then one line per finding naming the tab, the -// part, the value, the limit and the remedy, at most maxRefusalParts of them, -// and one closing line counting the parts not named. Every line passes -// termsafe.Sanitize, so no value the agent wrote reaches the terminal raw. +// writeLimitsRefusal writes the refusal the host's deny carries: one head line +// counting the parts that refuse, then, when why is set, the one line saying +// why a question without abcd's chip is abcd's, then one line per refusing +// finding naming the tab, the part, the value, the limit and the remedy, at +// most maxRefusalParts of them, and one closing line counting the parts not +// named. Every line passes termsafe.Sanitize, so no value the agent wrote +// reaches the terminal raw. +// +// The rows limit alone never refuses (rowsOnly), so a rows finding is never +// one of the parts to fix and is not counted among them +// (iss-2610100626327722): listed among them, the agent read it as a cause and +// cut a question the gate would have shown. It follows the refusing parts +// instead, after rowsNotRefusingLine, bounded as they are. It is called only +// with at least one refusing finding. func writeLimitsRefusal(w io.Writer, findings []question.Finding, why string) { - fmt.Fprintf(w, "Blocked by the abcd guard (question tool): %d part(s) of this question break abcd's asking rules; fix each and ask again.\n", len(findings)) + var refusing, rows []question.Finding + for _, f := range findings { + if f.Rule == question.RuleRows { + rows = append(rows, f) + } else { + refusing = append(refusing, f) + } + } + fmt.Fprintf(w, "Blocked by the abcd guard (question tool): %d part(s) of this question break abcd's asking rules; fix each and ask again.\n", len(refusing)) if why != "" { fmt.Fprintln(w, why) } - for _, f := range findings[:min(len(findings), maxRefusalParts)] { + for _, f := range refusing[:min(len(refusing), maxRefusalParts)] { fmt.Fprintln(w, termsafe.Sanitize(f.String())) } - if more := len(findings) - maxRefusalParts; more > 0 { + if more := len(refusing) - maxRefusalParts; more > 0 { fmt.Fprintf(w, "... and %d more part(s); fix these and ask again to see the rest.\n", more) } + if len(rows) == 0 { + return + } + fmt.Fprintln(w, rowsNotRefusingLine) + for _, f := range rows[:min(len(rows), maxRefusalParts)] { + fmt.Fprintln(w, termsafe.Sanitize(f.String())) + } + if more := len(rows) - maxRefusalParts; more > 0 { + fmt.Fprintf(w, "... and %d more tab(s) over the rows limit.\n", more) + } } +// rowsNotRefusingLine opens the rows findings a refusal names but does not +// refuse on. +const rowsNotRefusingLine = "The rows limit does not refuse on its own: with only the rows finding(s) below, this question would have been shown, and a narrow window might cut it." + // questionFailOpen is the gate's failOpen: exit 1, which lets the question run // and keeps the warning in front of a human. func questionFailOpen(w io.Writer, format string, err error) error { @@ -309,11 +362,14 @@ func questionFailOpen(w io.Writer, format string, err error) error { return &exitError{Code: 1} } -// resetModeOnAnswer is the prompt hook's half: when a question is marked open, -// this human message is its answer, so the mode goes back to managed, the -// marker is cleared, and one line on stderr says so. Nothing goes to stdout, -// which the host injects into the session's context. Every failure is named -// and never stops the prompt. +// resetModeOnAnswer is the reset an answer triggers, run from two hooks: the +// question tool's PostToolUse hook, when the answer comes back +// (iss-2610100626211810), and the prompt hook, on the next human message, for +// a host that does not run PostToolUse. When a question is marked open the +// mode goes back to managed, the marker is cleared, and one line on stderr +// says so; whichever hook runs first clears the marker, so the other changes +// nothing. Nothing goes to stdout, which the host hands the agent. Every +// failure is named and never stops the session. func resetModeOnAnswer(w io.Writer, cwd string) { root, err := mode.Root(cwd) if err != nil { diff --git a/internal/surface/cli/guard_question_limits_test.go b/internal/surface/cli/guard_question_limits_test.go index a6555001b..6db63c41c 100644 --- a/internal/surface/cli/guard_question_limits_test.go +++ b/internal/surface/cli/guard_question_limits_test.go @@ -136,6 +136,11 @@ func refusal(t *testing.T, stderr string) (head string, parts []namedPart, lines } more := 0 for _, l := range all[1:] { + if strings.HasPrefix(l, rowsNotRefusingPrefix) { + // The rows findings that follow are named but refuse nothing + // (iss-2610100626327722); rowsAfterRefusal reads them. + break + } if m := moreLineRe.FindStringSubmatch(l); m != nil { more, _ = strconv.Atoi(m[1]) continue @@ -158,6 +163,34 @@ func refusal(t *testing.T, stderr string) (head string, parts []namedPart, lines return head, parts, lines } +// rowsNotRefusingPrefix opens the line a refusal for another fault writes +// before the rows findings it names but does not refuse on. +const rowsNotRefusingPrefix = "The rows limit does not refuse on its own" + +// rowsAfterRefusal returns the parts a refusal names after its rows line: the +// rows findings that are not a cause of the refusal. It returns nil when the +// refusal has no rows line. +func rowsAfterRefusal(reason string) (line string, parts []namedPart) { + all := strings.Split(strings.TrimRight(reason, "\n"), "\n") + for i, l := range all { + if !strings.HasPrefix(l, rowsNotRefusingPrefix) { + continue + } + line = l + for _, r := range all[i+1:] { + if m := findingLineRe.FindStringSubmatch(r); m != nil { + tab := 0 + if strings.HasPrefix(m[1], "tab ") { + tab, _ = strconv.Atoi(strings.TrimPrefix(m[1], "tab ")) + } + parts = append(parts, namedPart{Tab: tab, Part: m[2], Rule: m[3]}) + } + } + break + } + return line, parts +} + func rulesNamed(parts []namedPart) []string { out := make([]string, len(parts)) for i, p := range parts { @@ -267,7 +300,7 @@ func TestRecommendedStarredOrLongHeaderIsRefused(t *testing.T) { // TestProductThinkerQuestionNamesNoRecordOrCommand is criterion R4 at the // hook. With the mode at product-thinker a record number, a command named by // one of the binary's verbs, and a label in backticks are each refused; the -// same questions under the facilitator mode are admitted. With no mode store, +// same questions chipped for the facilitator are admitted. With no mode store, // the "Product" chip stands in for the mode and refuses the same way. func TestProductThinkerQuestionNamesNoRecordOrCommand(t *testing.T) { cases := []struct { @@ -295,8 +328,12 @@ func TestProductThinkerQuestionNamesNoRecordOrCommand(t *testing.T) { t.Errorf("product thinker: want one register finding; got %v:\n%s", rulesNamed(parts), reason) } + // The chip names the addressee (iss-2610100626211810): the same + // question chipped for the facilitator is theirs to read. setMode(t, root, mode.Facilitator) - if stdout, stderr, code := runGuard(askPayload(t, root, q), "guard", "hook"); code != 0 || stdout != "" || stderr != "" { + tech := q + tech.Header = "Tech Q2" + if stdout, stderr, code := runGuard(askPayload(t, root, tech), "guard", "hook"); code != 0 || stdout != "" || stderr != "" { t.Errorf("facilitator: the mechanism and the ids are theirs; code=%d stdout=%q stderr=%q", code, stdout, stderr) } @@ -356,33 +393,39 @@ func TestForeignQuestionIsNotRefused(t *testing.T) { } } -// TestAbcdChipWhileManagedIsRefused is the other half of the mode gate's -// scope: an abcd chip while the mode reads managed is refused with the -// existing one-line refusal and its `abcd mode` remedy, and nothing is marked -// open. With a field finding as well, both are named: the finding lines first, -// then the mode's line. -func TestAbcdChipWhileManagedIsRefused(t *testing.T) { +// TestAbcdChipWhileManagedIsNeverRefusedOnTheMode is the other half of the +// mode gate's scope (iss-2610100626211810): an abcd chip names whom the +// question is for, so while the mode reads managed it is admitted and sets the +// mode from the chip. With a field finding it is refused on that finding +// alone: no line about the mode, the mode left managed, nothing marked open. +func TestAbcdChipWhileManagedIsNeverRefusedOnTheMode(t *testing.T) { root := managedCheckout(t) q := wellBuilt() q.Header = "Product Q1" + q.Options[0].Label = "Keep it (Recommended)" stdout, stderr, code := runGuard(askPayload(t, root, q), "guard", "hook") reason := mustDeny(t, stdout, stderr, code) - if reason != questionRefusal { - t.Errorf("want the existing one-line refusal alone; reason = %q", reason) + if _, parts, _ := refusal(t, reason); !slices.Equal(rulesNamed(parts), []string{"never-recommended"}) { + t.Errorf("want the one field finding; got %v:\n%s", rulesNamed(parts), reason) + } + for _, not := range []string{"mode reads managed", "abcd mode"} { + if strings.Contains(reason, not) { + t.Errorf("a chipped question is never refused on the mode, yet the refusal says %q:\n%s", not, reason) + } + } + if got, _ := mode.ReadAt(root); got != mode.Managed { + t.Errorf("a refused question set the mode to %q", got) } if markedOpen(t, root) { t.Error("a refused question was marked open") } - q.Options[0].Label = "Keep it (Recommended)" - stdout, stderr, code = runGuard(askPayload(t, root, q), "guard", "hook") - reason = mustDeny(t, stdout, stderr, code) - if _, parts, _ := refusal(t, reason); !slices.Equal(rulesNamed(parts), []string{"never-recommended"}) { - t.Errorf("want the one field finding; got %v:\n%s", rulesNamed(parts), reason) + q.Options[0].Label = "Keep it" + if stdout, stderr, code := runGuard(askPayload(t, root, q), "guard", "hook"); code != 0 || stdout != "" || stderr != "" { + t.Fatalf("the fixed question must be admitted silently; code=%d stdout=%q stderr=%q", code, stdout, stderr) } - all := strings.Split(strings.TrimRight(reason, "\n"), "\n") - if all[len(all)-1] != questionRefusal { - t.Errorf("the mode's refusal must close the lines; reason:\n%s", reason) + if got, _ := mode.ReadAt(root); got != mode.ProductThinker { + t.Errorf("mode = %q, want the chip's product-thinker", got) } } diff --git a/internal/surface/cli/guard_question_rows_test.go b/internal/surface/cli/guard_question_rows_test.go index 36b130956..21434286e 100644 --- a/internal/surface/cli/guard_question_rows_test.go +++ b/internal/surface/cli/guard_question_rows_test.go @@ -112,8 +112,10 @@ func TestRowsNoteIsBounded(t *testing.T) { } // TestRowsWithAnotherFindingIsDenied: the rows limit is admitted only alone. A -// tall question that also breaks another rule is refused, naming every -// finding, the rows one included. +// tall question that also breaks another rule is refused, and the refusal +// counts and lists only the refusing parts as the parts to fix; the rows +// finding follows them, on a line saying the rows limit does not refuse on its +// own and the question would have been shown (iss-2610100626327722). func TestRowsWithAnotherFindingIsDenied(t *testing.T) { root := managedCheckout(t) setMode(t, root, mode.ProductThinker) @@ -121,31 +123,42 @@ func TestRowsWithAnotherFindingIsDenied(t *testing.T) { q.Options[0].Label = "Keep it (Recommended)" stdout, stderr, code := runGuard(askPayload(t, root, q), "guard", "hook") reason := mustDeny(t, stdout, stderr, code) - _, parts, _ := refusal(t, reason) - if got := rulesNamed(parts); !slices.Contains(got, string(question.RuleRows)) || !slices.Contains(got, "never-recommended") { - t.Errorf("want the rows and the never-recommended findings both named; got %v:\n%s", got, reason) + head, parts, _ := refusal(t, reason) + if got := rulesNamed(parts); !slices.Equal(got, []string{"never-recommended"}) { + t.Errorf("want only the never-recommended finding among the parts to fix; got %v:\n%s", got, reason) + } + if !strings.Contains(head, ": 1 part(s)") { + t.Errorf("the head line must count only the refusing part; head = %q", head) + } + line, rows := rowsAfterRefusal(reason) + if line == "" { + t.Fatalf("the refusal must say the rows limit does not refuse on its own; reason:\n%s", reason) + } + if !strings.Contains(line, "would have been shown") { + t.Errorf("the rows line must say the question would have been shown; line = %q", line) + } + if !slices.Equal(rulesNamed(rows), []string{string(question.RuleRows)}) { + t.Errorf("want the rows finding named after the rows line; got %v:\n%s", rulesNamed(rows), reason) } if markedOpen(t, root) { t.Error("a refused question was marked open") } } -// TestRowsOnlyWhileManagedIsDenied: the mode gate still refuses. A tall -// question asked while the mode reads managed is refused with the mode's line, -// and the rows finding is named beside it, as any finding is. -func TestRowsOnlyWhileManagedIsDenied(t *testing.T) { +// TestRowsOnlyWhileManagedIsAdmitted (iss-2610100626211810): the chip names +// whom the question is for, so a tall chipped question asked while the mode +// reads managed is admitted with the rows note, sets the mode from the chip +// and is marked open. +func TestRowsOnlyWhileManagedIsAdmitted(t *testing.T) { root := managedCheckout(t) q := tallQuestion() mustBeRowsOnly(t, q, question.ProductThinker) stdout, stderr, code := runGuard(askPayload(t, root, q), "guard", "hook") - reason := mustDeny(t, stdout, stderr, code) - if !strings.Contains(reason, questionRefusal) { - t.Errorf("the mode's refusal must be named; reason = %q", reason) - } - if _, parts, _ := refusal(t, reason); !slices.Equal(rulesNamed(parts), []string{string(question.RuleRows)}) { - t.Errorf("want the rows finding named; got %v:\n%s", rulesNamed(parts), reason) + mustAdmitWithNote(t, stdout, stderr, code) + if got, _ := mode.ReadAt(root); got != mode.ProductThinker { + t.Errorf("mode = %q, want the chip's product-thinker", got) } - if markedOpen(t, root) { - t.Error("a refused question was marked open") + if !markedOpen(t, root) { + t.Error("the admitted question was not marked open") } } diff --git a/internal/surface/cli/guard_question_test.go b/internal/surface/cli/guard_question_test.go index 0edcd36d1..99da93b60 100644 --- a/internal/surface/cli/guard_question_test.go +++ b/internal/surface/cli/guard_question_test.go @@ -25,41 +25,93 @@ func questionMarker(root string) string { return filepath.Join(root, filepath.FromSlash(mode.QuestionOpenRelPath)) } -// TestGuardRefusesAQuestionWhileManaged is criterion 2: with the mode reading -// managed, a question to the human through the host's question tool is refused -// with the host's deny, and the refusal names the two settings and the verb -// that sets them. Nothing is marked open. -func TestGuardRefusesAQuestionWhileManaged(t *testing.T) { - root := managedCheckout(t) - stdout, stderr, code := runGuard(questionCall(t, root), "guard", "hook") +// chipCall is questionCall with the chip header given. +func chipCall(t *testing.T, cwd, header string) string { + t.Helper() + q := wellBuilt() + q.Header = header + return askPayload(t, cwd, q) +} - reason := mustDeny(t, stdout, stderr, code) - for _, want := range []string{ - "abcd mode product-thinker", "abcd mode facilitator", - "the product thinker", "the technical facilitator", - } { - if !strings.Contains(reason, want) { - t.Errorf("the refusal must name %q; reason = %q", want, reason) - } - } - if strings.Contains(reason, "\n") { - t.Errorf("the refusal is one line; reason = %q", reason) +// chipStates are the chips the gate reads and the mode each one sets +// (iss-2610100626211810): Product names the product thinker, and Tech and +// Setup the technical facilitator. +var chipStates = []struct { + header string + want mode.State +}{ + {"Product Q1", mode.ProductThinker}, + {"Tech Q3", mode.Facilitator}, + {"Setup Q1/4", mode.Facilitator}, +} + +// TestGuardChipSetsTheModeWhileManaged (iss-2610100626211810): a question +// carrying abcd's chip already names whom it is for, so with the mode reading +// managed it is never refused on the mode. It runs silently, the mode is set +// from the chip's role, and the question is marked open for the reset. +func TestGuardChipSetsTheModeWhileManaged(t *testing.T) { + for _, tc := range chipStates { + t.Run(tc.header, func(t *testing.T) { + root := managedCheckout(t) + stdout, stderr, code := runGuard(chipCall(t, root, tc.header), "guard", "hook") + if code != 0 || stdout != "" || stderr != "" { + t.Fatalf("a chipped question must be admitted silently while managed; code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + if got, err := mode.ReadAt(root); err != nil || got != tc.want { + t.Errorf("mode after the question = %q (%v), want %q", got, err, tc.want) + } + data, err := os.ReadFile(questionMarker(root)) + if err != nil { + t.Fatalf("the admitted question was not marked open: %v", err) + } + if strings.TrimSpace(string(data)) != string(tc.want) { + t.Errorf("marker = %q, want %q", data, tc.want) + } + }) } - if _, err := os.Lstat(questionMarker(root)); !errors.Is(err, os.ErrNotExist) { - t.Errorf("a refused question was marked open: %v", err) +} + +// TestGuardChipReSetsADifferentMode (iss-2610100626211810): the status line +// names whom the question on screen is for, so a chip whose role differs from +// the mode a person or an earlier stop set re-sets it to the chip's role. +func TestGuardChipReSetsADifferentMode(t *testing.T) { + for _, tc := range []struct { + from mode.State + header string + want mode.State + }{ + {mode.Facilitator, "Product Q2", mode.ProductThinker}, + {mode.ProductThinker, "Tech Q1", mode.Facilitator}, + {mode.ProductThinker, "Setup Q2", mode.Facilitator}, + } { + t.Run(string(tc.from)+"/"+tc.header, func(t *testing.T) { + root := managedCheckout(t) + setMode(t, root, tc.from) + stdout, stderr, code := runGuard(chipCall(t, root, tc.header), "guard", "hook") + if code != 0 || stdout != "" || stderr != "" { + t.Fatalf("the question must be admitted silently; code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + if got, _ := mode.ReadAt(root); got != tc.want { + t.Errorf("mode = %q, want the chip's %q", got, tc.want) + } + if !markedOpen(t, root) { + t.Error("the admitted question was not marked open") + } + }) } } -// TestGuardAdmitsAQuestionOnceAddressed: with the mode naming whom the agent is -// asking, the question runs silently and is marked open for the reset. +// TestGuardAdmitsAQuestionOnceAddressed: with the mode already naming the +// person the chip names, the question runs silently, the mode stays, and the +// question is marked open for the reset. func TestGuardAdmitsAQuestionOnceAddressed(t *testing.T) { - for _, st := range []mode.State{mode.ProductThinker, mode.Facilitator} { + for st, header := range map[mode.State]string{mode.ProductThinker: "Product Q1", mode.Facilitator: "Tech Q1"} { t.Run(string(st), func(t *testing.T) { root := managedCheckout(t) if err := mode.SetAt(root, st); err != nil { t.Fatal(err) } - stdout, stderr, code := runGuard(questionCall(t, root), "guard", "hook") + stdout, stderr, code := runGuard(chipCall(t, root, header), "guard", "hook") if code != 0 || stdout != "" || stderr != "" { t.Fatalf("an addressed question must be admitted silently; code=%d stdout=%q stderr=%q", code, stdout, stderr) } @@ -70,6 +122,9 @@ func TestGuardAdmitsAQuestionOnceAddressed(t *testing.T) { if strings.TrimSpace(string(data)) != string(st) { t.Errorf("marker = %q, want %q", data, st) } + if got, _ := mode.ReadAt(root); got != st { + t.Errorf("mode = %q, want %q", got, st) + } }) } } @@ -111,10 +166,11 @@ func TestGuardQuestionGateFailsOpenLoud(t *testing.T) { } // TestGuardQuestionGateFailsOpenWhereTheModeCannotBeSet -// (iss-2609260100382261): a refusal whose remedy cannot run refuses forever. -// With the tier present but not writable the mode reads managed, and `abcd -// mode` would fail on permission denied, so the gate does not refuse: it lets -// the question run on the loud status, names why, and leaves nothing behind. +// (iss-2609260100382261, iss-2610100626211810): with the tier present but not +// writable, the chip's mode cannot be set and the question cannot be marked +// open. That is not a decision: the question runs on the loud, non-blocking +// status, the gate says why, the mode stays where it was, and nothing is left +// behind. func TestGuardQuestionGateFailsOpenWhereTheModeCannotBeSet(t *testing.T) { if os.Geteuid() == 0 { t.Skip("a read-only directory does not refuse root") @@ -129,17 +185,17 @@ func TestGuardQuestionGateFailsOpenWhereTheModeCannotBeSet(t *testing.T) { if _, _, err := runSplit(t, "", "mode", "facilitator"); err == nil { t.Fatal("precondition: `abcd mode facilitator` succeeded on a read-only tier") } - _, stderr, code := runGuard(questionCall(t, root), "guard", "hook") - if code != 1 { - t.Fatalf("a question whose remedy cannot run must fail open loud (exit 1); got %d (stderr %q)", code, stderr) + stdout, stderr, code := runGuard(questionCall(t, root), "guard", "hook") + if code != 1 || stdout != "" { + t.Fatalf("a question whose mode cannot be set must fail open loud (exit 1); got %d (stdout %q stderr %q)", code, stdout, stderr) } - for _, want := range []string{"NOT CHECKED", "UNGATED", "cannot be set"} { + for _, want := range []string{"NOT CHECKED", "UNGATED", "could not be"} { if !strings.Contains(stderr, want) { t.Errorf("the fail-open must say %q; stderr = %q", want, stderr) } } - if strings.Contains(stderr, questionRefusal) { - t.Errorf("the gate still named a remedy that cannot run; stderr = %q", stderr) + if got, _ := mode.ReadAt(root); got != mode.Managed { + t.Errorf("mode = %q, want it left managed", got) } ents, err := os.ReadDir(tier) if err != nil { @@ -150,6 +206,79 @@ func TestGuardQuestionGateFailsOpenWhereTheModeCannotBeSet(t *testing.T) { } } +// TestPostToolUseHookResetsTheModeOnTheAnswer (iss-2610100626211810): the +// question tool's PostToolUse hook is the answer coming back, so it runs the +// prompt hook's reset there and then: the mode goes back to managed, the +// marker is cleared, and one stderr line says so, with nothing on stdout, +// which the host would hand the agent. With no question open, or for a tool +// that is not a question tool, it changes nothing and says nothing. +func TestPostToolUseHookResetsTheModeOnTheAnswer(t *testing.T) { + root := managedCheckout(t) + if _, _, code := runGuard(chipCall(t, root, "Tech Q3"), "guard", "hook"); code != 0 { + t.Fatalf("precondition: the chipped question was not admitted (exit %d)", code) + } + if got, _ := mode.ReadAt(root); got != mode.Facilitator { + t.Fatalf("precondition: mode = %q, want facilitator", got) + } + answered := func(tool string) string { + return `{"session_id":"s-post","hook_event_name":"PostToolUse","tool_name":"` + tool + + `","tool_input":{"questions":[]},"tool_response":{"answers":{}},"cwd":"` + root + `"}` + } + + // Another tool's PostToolUse is none of the reset's business. + stdout, stderr, err := runSplit(t, answered("Bash"), "hook", "question-answered") + if err != nil || stdout != "" { + t.Fatalf("question-answered on another tool: err=%v stdout=%q stderr=%q", err, stdout, stderr) + } + if got, _ := mode.ReadAt(root); got != mode.Facilitator { + t.Errorf("another tool's PostToolUse moved the mode to %q", got) + } + + stdout, stderr, err = runSplit(t, answered(questionTools[0]), "hook", "question-answered") + if err != nil || stdout != "" { + t.Fatalf("question-answered: err=%v stdout=%q stderr=%q", err, stdout, stderr) + } + if got, _ := mode.ReadAt(root); got != mode.Managed { + t.Errorf("mode after the answer = %q, want managed", got) + } + if markedOpen(t, root) { + t.Error("the marker survived the answer") + } + if n := modeResetLines(stderr); n != 1 { + t.Errorf("want exactly one stderr line saying the mode was reset, got %d:\n%s", n, stderr) + } + + // A hand-set mode with no question open is left alone. + setMode(t, root, mode.ProductThinker) + _, stderr, err = runSplit(t, answered(questionTools[0]), "hook", "question-answered") + if err != nil { + t.Fatalf("question-answered: %v\n%s", err, stderr) + } + if got, _ := mode.ReadAt(root); got != mode.ProductThinker { + t.Errorf("with no question open the mode moved to %q", got) + } + if n := modeResetLines(stderr); n != 0 { + t.Errorf("no question was open, yet the hook reported a reset:\n%s", stderr) + } +} + +// TestQuestionAnswerHookIsInstalled holds the PostToolUse wiring: one entry, +// scoped to exactly the question tools the gate reads, whose script runs +// `hook question-answered`. +func TestQuestionAnswerHookIsInstalled(t *testing.T) { + doc := decodedHooksManifest(t) + entries := doc.Hooks["PostToolUse"] + if len(entries) != 1 || len(entries[0].Hooks) != 1 { + t.Fatalf("want one PostToolUse entry with one command; got %+v", entries) + } + if want := strings.Join(questionTools, "|"); entries[0].Matcher != want { + t.Errorf("the PostToolUse entry must be scoped to the question tools; matcher = %q, want %q", entries[0].Matcher, want) + } + if body := resolveHookCommand(t, entries[0].Hooks[0].Command); !strings.Contains(body, "hook question-answered") { + t.Errorf("the PostToolUse script must run `hook question-answered`; it runs:\n%s", body) + } +} + // TestPromptHookResetsTheModeAfterAQuestion is criterion 4: the next human // message after an admitted question resets the mode to managed, clears the // marker and says so in one stderr line; a message with no question open diff --git a/internal/surface/cli/homestop.go b/internal/surface/cli/homestop.go index a6a7c140f..5ef1335bd 100644 --- a/internal/surface/cli/homestop.go +++ b/internal/surface/cli/homestop.go @@ -71,6 +71,12 @@ func renderHomeStop(root *cobra.Command, args []string, stop *abcdhome.Stop, std _ = json.NewEncoder(stdout).Encode(hookStageResult{Hook: hook, Outcome: hookOutcomeNotCaptured, Reason: stop.Line}) } return 0 + case "hook question-answered": + // The answer's reset writes the mode store, so it waits like every + // other write; the line goes to stderr, and exit 0, because the + // question already ran and the prompt hook resets the mode later. + fmt.Fprintln(stderr, "abcd:", stop.Line) + return 0 case "guard hook": if admitsRename(stop, stdin) { return 0 diff --git a/internal/surface/cli/hooks_homestop_test.go b/internal/surface/cli/hooks_homestop_test.go index e0cd74224..75935eccb 100644 --- a/internal/surface/cli/hooks_homestop_test.go +++ b/internal/surface/cli/hooks_homestop_test.go @@ -31,6 +31,7 @@ var stopWrapperEvents = []struct { {"UserPromptSubmit", 1, []string{"hook prompt-router"}}, {"SessionStart", 2, []string{"hook session-start", "hook prompt-router-reset"}}, {"PreToolUse", 2, []string{"guard hook"}}, + {"PostToolUse", 1, []string{"hook question-answered"}}, {"PreCompact", 1, []string{"hook prompt-router-reset"}}, {"SessionEnd", 1, []string{"hook session-end"}}, {"SubagentStop", 1, []string{"hook subagent-stop"}}, diff --git a/internal/surface/cli/hooks_selfprovision_test.go b/internal/surface/cli/hooks_selfprovision_test.go index f15afa251..0740ede9d 100644 --- a/internal/surface/cli/hooks_selfprovision_test.go +++ b/internal/surface/cli/hooks_selfprovision_test.go @@ -50,6 +50,7 @@ type binaryHook struct { var binaryHooks = []binaryHook{ {event: "UserPromptSubmit", verb: "prompt-router"}, {event: "PreToolUse", verb: "guard"}, + {event: "PostToolUse", verb: "question-answered"}, {event: "PreCompact", verb: "prompt-router-reset"}, {event: "SessionEnd", verb: "session-end", neverBootstraps: true}, {event: "SubagentStop", verb: "subagent-stop", neverBootstraps: true}, diff --git a/internal/surface/cli/hooks_sessionstart_test.go b/internal/surface/cli/hooks_sessionstart_test.go index 7dd062b9d..367800a77 100644 --- a/internal/surface/cli/hooks_sessionstart_test.go +++ b/internal/surface/cli/hooks_sessionstart_test.go @@ -28,7 +28,8 @@ import ( // what is asserted is modelled; anything else in the manifest is ignored. type sessionStartHooks struct { Hooks map[string][]struct { - Hooks []struct { + Matcher string `json:"matcher"` + Hooks []struct { Type string `json:"type"` // Timeout is a pointer so an absent field (the host's per-event // default) reads differently from a declared one. diff --git a/internal/surface/cli/hooks_timeout_test.go b/internal/surface/cli/hooks_timeout_test.go index 791ae7531..ea649d94c 100644 --- a/internal/surface/cli/hooks_timeout_test.go +++ b/internal/surface/cli/hooks_timeout_test.go @@ -31,6 +31,7 @@ var wantHookTimeouts = map[string]int{ "SessionStart": 240, "UserPromptSubmit": 120, "PreToolUse": 0, + "PostToolUse": 0, "PreCompact": 0, "SessionEnd": 0, "SubagentStop": 0, @@ -109,7 +110,7 @@ func TestEverySalvageEntryShowsItsWait(t *testing.T) { } } slices.Sort(provisioning) - if want := []string{"PreCompact", "PreToolUse", "SessionStart", "UserPromptSubmit"}; !slices.Equal(provisioning, want) { + if want := []string{"PostToolUse", "PreCompact", "PreToolUse", "SessionStart", "UserPromptSubmit"}; !slices.Equal(provisioning, want) { t.Errorf("the entries that run hooks/bootstrap.sh are %v, want %v — a hook that starts or stops provisioning updates the timeout table too", provisioning, want) } } diff --git a/internal/surface/cli/mode.go b/internal/surface/cli/mode.go index a91f0c8af..228e56052 100644 --- a/internal/surface/cli/mode.go +++ b/internal/surface/cli/mode.go @@ -38,17 +38,19 @@ func newModeCommand(asJSON *bool) *cobra.Command { "on the product thinker, who answers on a surface of their own). An absent\n" + "store reads as `managed`.\n\n" + "`abcd mode ` sets it. Two writers share the verb: the agent runs it\n" + - "when it stops for a verdict, naming whom it is addressing, and the human runs\n" + - "it by hand to say which hat they wear. The state lives per checkout at\n" + + "when it stops for a verdict other than a question, naming whom it is\n" + + "addressing, and the human runs it by hand to say which hat they wear. The state lives per checkout at\n" + "`.abcd/.work.local/mode`, so only a repository abcd manages — one that has\n" + "the local-ephemeral tier — can hold it; elsewhere the set refuses and creates\n" + "nothing. The next status-line refresh and the bare `abcd` board read the\n" + "same file.\n\n" + - "The guard holds the agent to it: a question through the host's question\n" + - "tool is refused while the state reads `managed`, and once the state names\n" + - "somebody the question runs and is marked open. The next human message is\n" + - "its answer, so the prompt hook resets the state to `managed` and says so\n" + - "once on stderr. A state set by hand with no question open is left as it is.\n\n" + + "A question needs no setting: when the guard admits abcd's question through\n" + + "the host's question tool, it sets the state from the question's chip\n" + + "(Product to product-thinker, Tech and Setup to facilitator) and marks the\n" + + "question open. The answer resets the state to `managed` and says so once on\n" + + "stderr: the question tool's PostToolUse hook resets it when the answer comes\n" + + "back, and the prompt hook on the next human message where the host runs no\n" + + "PostToolUse. A state set by hand with no question open is left as it is.\n\n" + "Where this machine has no status surface — no `" + abcdhome.Display("statusline.json") + "`, or\n" + "one with `disabled` set — the set form prints one line naming whose answer\n" + "is owed, once, because the verb call is the stop. Setting `managed` owes\n" + diff --git a/internal/surface/cli/roles_vocabulary_test.go b/internal/surface/cli/roles_vocabulary_test.go index aec23f206..6c1ae2b4f 100644 --- a/internal/surface/cli/roles_vocabulary_test.go +++ b/internal/surface/cli/roles_vocabulary_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/mode" + "github.com/intentdriven/abcd/internal/core/question" "github.com/spf13/cobra" ) @@ -99,16 +100,29 @@ var questionBlock = regexp.MustCompile(`(?:(?:^|[.!?:;,—]\s+|\*\*\s*|\bthen\s+ // TestPluginQuestionBlocksNameTheAddressee (AC3): every question a plugin page // has the agent put to a human names which of the two roles it asks, in the -// paragraph that asks it, and the page's section sets the mode to that role — -// the addressee comes from the mode (itd-2609212130146198), whose vocabulary -// this test reads rather than restating. +// paragraph that asks it, and the page's section says how the mode comes to +// name that role: the question's chip, which sets the mode when the guard +// admits the question (iss-2610100626211810), or `abcd mode` at a stop that is +// not a question (itd-2609212130146198). The test reads the mode's vocabulary +// and the chip roles rather than restating them. func TestPluginQuestionBlocksNameTheAddressee(t *testing.T) { - type role struct{ name, setter string } + type role struct { + name string + setters []string + } var roles []role for _, s := range mode.States() { - if who := s.Addressee(); who != "" { - roles = append(roles, role{name: who, setter: "mode " + string(s)}) + who := s.Addressee() + if who == "" { + continue } + r := role{name: who, setters: []string{"mode " + string(s)}} + for _, chip := range question.Default.ChipRoles { + if chipRoleState(chip) == s { + r.setters = append(r.setters, "`"+chip+"` chip") + } + } + roles = append(roles, r) } if len(roles) != 2 { t.Fatalf("the mode names %d roles, want the product thinker and the technical facilitator", len(roles)) @@ -134,9 +148,13 @@ func TestPluginQuestionBlocksNameTheAddressee(t *testing.T) { continue } named = true - if !strings.Contains(flat, r.setter) { - t.Errorf("%s:%d asks the %s but its section never sets `abcd %s` first", - filepath.ToSlash(p), para.line, r.name, r.setter) + set := false + for _, setter := range r.setters { + set = set || strings.Contains(flat, setter) + } + if !set { + t.Errorf("%s:%d asks the %s but its section names none of %q, so nothing says the mode names them", + filepath.ToSlash(p), para.line, r.name, r.setters) } } if !named {