feat: the AI-written interviews in a plain Terminal, with a change guard around every turn - #814
Merged
Merged
Conversation
WalkConfigValueQuestions restates install's early gates in a second copy, so a gate added to install() before its first value question would be silently missing from the walk, and an answers file would be settled against questions the install never asks. The new test feeds each early refusal (an unmanaged folder, the .abcd hazard, a stale binary, a retired docs target, a declined adoption, an unapproved settings change) to both Install and the walk and asserts neither puts a config value question; a control case with no gate proves both would. Assisted-by: Claude:claude-opus-5-5
The plain-Terminal interviews (spc-2610030911534855) dispatch a role the person routed to a runner with no host session and, often, no runner.fallback_host. Dispatch refused that as "nowhere to land". A new Dispatcher.Attended field says the person is at the terminal: a role routed to a runner then runs there with no configured host behind it, and a runner that does not answer (an invalid answer included) still writes its fallback receipt, naming none as the route that ran, and stops the run with an error naming the runner, the reason and runner.fallback_host. A role on the host still needs a host session or a configured host, attended or not, and the implement loop, which never sets the field, is unchanged. Decision taken: the question-returning contract lives with the turn loop in internal/core/interview, not here, so the runner stays role-agnostic; this seam is the runner's only change. Assisted-by: Claude:claude-opus-5-5
…heck ask.Safe's body moves to question.Safe, and ask.Safe delegates to it. The AI-written interviews' check (internal/core/interview) must sanitise a runner's question before it measures it, so what is held to the limits is what is drawn (spc-2610030911534855, B7), and core cannot import the front door. One copy serves both; the drawing's behaviour is unchanged. Assisted-by: Claude:claude-opus-5-5
interview.Written runs planning and the retrospective in a plain Terminal
(spc-2610030911534855, step 4): one dispatch per turn (open question 1,
decided (a)) through runner.Dispatcher with no host session and Attended
set, the role on the person's own route. Each turn's brief, in the local
tier under .abcd/.work.local/interview-turns/<interview>-<stamp>/, states
the task, the receipt contract, the asking rules, the seed and the answers
so far as untrusted data. The receipt is exactly one of {"ask": ...} or
{"done": ...}, read strictly through os.Root; an ask is sanitised
(question.Safe) and then held to question.Check and the asking limits
before anything is drawn, a done to the interview's own outcome check, and
a receipt that fails either is the dispatcher's ReasonInvalid fallback,
recorded and, with no configured host, refused. abcd numbers the questions
Q1, Q2, ... across the interview, so an answers file replays a drawn run by
ordinal. Every end writes the answers record through interview.Write with
the answers given and any fallback receipt (Record gains fallbacks,
omitted when empty), except a front door's stop marked ErrNothingRecorded.
The no-route refusal (NoRouteError) and a missing local tier refuse before
anything is written. A writer's answerable refusal (a thin retrospective
answer) goes back to the role in the next brief. A front door may wrap each
dispatch's context (DispatchContext) so an interrupt kills the runner and
keeps the answers given (ErrInterrupted).
Decisions taken: the receipt contract lives here rather than in
internal/core/runner, which stays role-agnostic; the turns sit beside the
records directory, never in it.
Assisted-by: Claude:claude-opus-5-5
…erminal Step 4 of spc-2610030911534855: `abcd reflect interview <release-tag>` and `abcd intent interview <itd-N>` run the AI-written interviews through the person's own route (itd-2610030810370060 decision 1). The front door reads the machine's runner configuration, refuses before anything runs when no route of the person's reaches a runner (exit 2, naming roles.<role>.runner, the machine's file and `abcd ahoy install`, nothing written), draws each question when stdin, stdout and stderr are all terminals, and otherwise answers it from --answers by ordinal, the file running out refusing exit 2 with nothing recorded; a run with neither refuses before any runner starts. --answered-in stamps an entry that names no place. While a runner writes, SIGINT, SIGTERM and SIGHUP cancel the dispatch so the runner's process group is killed and the answers given are kept (exit 130); no handler is in force while a question is drawn. The retrospective's done is filed through reflect write's own path, floors and refusals unchanged; unshipped targets refuse before any runner starts until --proceed, and a thin answer goes back to the role at most twice. The planning interview's outcome is the role's own edits with Read, Edit, Grep and Glob (open question 4, decided (a)); the verb then reports the readiness gate, and the plan act stays the product thinker's. The planning-interviewer joins the roster at frontier with its agent page and injection canary; the reflection-composer page gains its plain-Terminal turn contract (0.3.0). The plugin pages, the brief's surface and agent chapters, the naming register, the release-gate context (its hash and the example receipt's), the sentences, the worked examples, the surface snapshot and the command reference move with the surface. The turn brief's fences are allowlisted in the one-fence-rule detector: it writes them and reads none. Decision taken: a drawn question takes a choice, not typed prose, so in a Terminal the retrospective's role offers drafts of a section's answer as the options; the agent page and the brief say so. Assisted-by: Claude:claude-opus-5-5
… is drawn A question that passes the structural check but breaks an asking limit (a chip outside the chip grammar) is the dispatcher's invalid answer, as a structural fault is: nothing is drawn and the fallback names the header. Watched red with the limits dropped from the receipt check. Assisted-by: Claude:claude-opus-5-5
…rviews Assisted-by: Claude:claude-opus-5-5
…s answer The turn loop read whatever stood at the turn's receipt path once the runner exited, so a role that wrote the next turn's receipt ahead had its next silent turn answered by it, and a fallback host's silence was answered by the receipt the failed runner left. The dispatcher gains Prepare, consulted before each runner starts; a *Failure it returns is that runner's failure, with nothing launched. The interview's Prepare refuses a receipt standing before the turn's first runner as the role's invalid answer, and removes what a failed runner left before a fallback runner starts. Assisted-by: Claude:claude-opus-5-5
On Ctrl-C while a runner wrote the next question, the dispatcher recorded
the killed runner as a failed fallback ("was stopped ... its process group
was killed") before the loop joined ErrInterrupted, so the answers record
reported a runner failure the person caused.
The record closure now skips the receipt while the front door's interrupt
has ended the dispatch. The interrupt relay becomes a package seam, and a
CLI test holds the interrupt to exit 130 with the answers given kept.
Assisted-by: Claude:claude-opus-5-5
…swers-file value Two protections passed with their code removed. A seed and a note carrying a four-backtick fence and a heading must reach the brief escaped, leaving exactly its three fences; and an answers-file value the question does not offer must refuse at the front door, exit 2, naming the offered values and writing no record, rather than reach the core's own check, which records. Assisted-by: Claude:claude-opus-5-5
reflect interview handed every writer error to reflectRefuse, whose default maps a fault that is not a refusal the person answers to exit 2, the code that promises nothing was written; by then the interview's answers record stands. Such a fault now exits 1 and says the record stands; the answerable refusals keep their mapping. Assisted-by: Claude:claude-opus-5-5
…ay change A role runs on the person's route in the checkout with write tools and dontAsk, and with no host session nobody watched its edits between drawn questions: a contributed criterion could have the planning role edit .githooks/pre-push and the verb would still report READY. Around each dispatch the loop now reads the working tree's state, git's status listing (porcelain v1, -z, every untracked file, through gitutil's isolated environment, as the reading assembler and the capture ledger read it) with each listed path's mode and content hash, the local tier left out. A path the dispatch changed that the interview does not grant stops it with *UnexpectedChangesError naming each; the answers given are recorded. The planning interview grants the intent's record alone and reports changed_paths; under --json its refusal is unexpected_changes with every path changed. The retrospective grants nothing. Exit 1 in both. The brief's reflect chapter also states the writer-fault exit 1 of the previous commit. Assisted-by: Claude:claude-opus-5-5
The runner's prompt tells the role to read its brief, and the claude runner launches with dontAsk, so the abcd model is that every tool the launch does not grant is denied. The retrospective's role was launched with --allowedTools=Write alone; it now carries Read beside it, and nothing more. A test holds both interviews' grants on the launch argv. The claude runner's comment on the brief's directory now covers an interview's turns, which sit inside the repository's local tier. Assisted-by: Claude:claude-opus-5-5
gitutil.Status runs `git --no-optional-locks status --porcelain=v1 -z --untracked-files=all` through the isolated environment, refused rather than truncated past its cap, and gitutil.ParseStatus is the one parser of its records: each entry's columns, its path verbatim, and a rename's or copy's source carried as Orig. StatusOptions holds the variants the callers need: ignored paths (--ignored=matching) and pathspecs. The four copies move onto it: the reading assembler's dirty-path gate, the capture ledger's uncommitted marker, the peers listing's clean-records check (which needs only the emptiness) and the interview's tree guard. Their tests are unchanged and pass. Assisted-by: Claude:claude-opus-5-5
…ectory around each turn The guard that holds an AI-written interview's role to the paths it was granted read only what git status lists, outside the local tier. A role holding Write could therefore leave, unseen, a hook under .git/hooks (run on the person's next git command), a change to .git/config (a hooksPath, an alias, a credential helper), a pre-push receipt under .abcd/.work.local/preflight-receipts/ or a rewritten handover, or any gitignored file (a repository's .claude/ settings among them). Each reading now also holds: - every ignored path, from the same status listing with --ignored=matching, an ignored directory (and an untracked nested repository) walked whole, each file by mode, size and modification time rather than a content hash, since ignored trees can be large; - the local tier like any other path, but for the run's own turn directory (abcd's briefs, the role's receipts) and the opt-in local transcript store, both written by abcd itself during a dispatch; - git's own hooks/, info/, config and config.worktree, read in the common git directory (so a linked worktree is covered) and a linked worktree's .git file, by mode, size and content hash; - every directory core.hooksPath names, in any scope the person's git reads (gitutil.HooksPaths, under the scrubbed rather than the isolated environment), when it is outside the working tree. A reading past 500,000 paths is refused rather than read in part. The run's turn directory is made before the first reading, so its making is not laid at the role's door. Assisted-by: Claude:claude-opus-5-5
One conflict, in internal/surface/cli/ask/safe.go: this branch made ask.Safe a delegate of question.Safe, and main added the typed part's prompt to the fields ask.Safe sanitises. The delegate is kept and question.Safe sanitises the typed prompt, so the drawing and the interviews' check still share one copy. Assisted-by: Claude:claude-opus-5-5
A configuration value may hold a newline, and git keeps it as one value; splitting the listing on newlines turned one hooks directory into two that git never runs a hook from, so the interview guard read the wrong places. git config -z ends each value with a NUL instead. Assisted-by: Claude:claude-opus-5-5
…ectories; exempt two inert paths The re-check of the guard's fix round (N1-N4) found four gaps. A core.hooksPath value is resolved through every link before it is placed and read, so a dotfiles-managed hooks directory, or an in-tree link to a directory elsewhere, is read where git runs its hooks from, not by its link text. The tree, git's directories and the hooks directory are compared by their real paths, so an in-tree hooks directory spelled through a link is named once, by its place in the tree. HEAD, packed-refs and refs/ are read in the common directory (and a linked worktree's own HEAD), and each submodule's hooks and configuration under modules/, whose names may hold a slash and which may nest. The stop says how many paths changed while the role ran, since abcd cannot tell who wrote them. Two paths that execute nothing and that something else on the machine writes in that window are left out: a file named .DS_Store anywhere, and the one path .claude/scheduled_tasks.lock. Every other .claude/ path stays watched (the host's settings can name hooks), and a second interview in the same checkout still stops this one, which the docs now say. The 500,000-path bound is pinned by a test at and past a lowered bound. Assisted-by: Claude:claude-opus-5-5
An entry of git's own directory that is a link (a dotfiles-managed hooks directory, info directory or configuration file, one hook, or a submodule's hooks directory) was recorded by its link text alone, so a role writing where it leads went unseen though git follows the link and runs or reads what is there. A link met while reading git's directories or a core.hooksPath directory is now recorded and its target read too, once, never following a link found there. A linked worktree's .git file is not followed: the git directory it names is read on its own. Refs: iss-2610040639162928 Assisted-by: Claude:claude-opus-5-5
…k in git's directory where it leads Resolves: iss-2610040639162928 Assisted-by: Claude:claude-opus-5-5
…ks link spelled by the tree's real path The second case catches an unresolved core.hooksPath on every platform, not only where the temporary root is itself a link. Assisted-by: Claude:claude-opus-5-5
Refs: iss-2610040739124513 Assisted-by: Claude:claude-opus-5-5
…e's push receipts The plain-Terminal guard read no entry of the common directory's worktrees/ but the current one's HEAD and config.worktree, and no worktree's local tier but its own. A role allowed only Write could register a worktree whose gitdir names its own turn directory (left out of the reading) and plant a push receipt there, or plant one in a sibling worktree's local tier: the pre-push gate takes a receipt from any worktree git lists, so the next push of that commit passed it. It could also repoint a sibling's commondir at a fake common directory whose configuration carries an alias, which runs on the person's next git command there. The reading now records every worktrees/ entry by its kind (one made or removed is noticed) and its HEAD, commondir, gitdir, config.worktree and locked by mode, size and content hash, following a link as git does; and the preflight-receipts/ directory of every worktree git lists. A base that is not a directory reads as holding nothing, so a listed worktree path that names a file cannot fail the reading. The pre-push script is left unchanged: refusing a receipt found only in a prunable worktree closes nothing here, since writing the fake worktree's .git file as well makes git list it as an ordinary one (probed on a scratch repository), and the script is a convenience gate CI stands behind. The probes the final re-check described are tests, as is a link cycle under .git/hooks, and a worktree the person makes between dispatches is not laid at the role's door. Refs: iss-2610040739124513 Assisted-by: Claude:claude-opus-5-5
…t the interview guard Resolves: iss-2610040739124513 Assisted-by: Claude:claude-opus-5-5
…rviews Assisted-by: Claude:claude-opus-5-5
The guard now hashes every listed worktree's HEAD and push receipts, so a commit, checkout or preflight in a sibling worktree during a turn stops the interview, as a second interview in the same checkout does. The re-check of the worktree fix asked for the page to say so. Assisted-by: Claude:claude-opus-5-5
Refs: iss-2610040847166532 Assisted-by: Claude:claude-opus-5-5
…hinker's rulings The four steps of the plain-Terminal spec are built. On the product thinker's three rulings of 2026-10-04 (the decision log, verbatim), the spec closes with a remainder and itd-2610030810370060 stays planned: "Ship it as partly done", "Add it to the remaining work" (typed answers in the AI-written interviews) and "Add a real run to the remaining work" (one real interview with the person). The remainder spec, spc-2610040847280931, carries those two, setup's answers-file gap and the guard's unbounded link walk, both still open. Refs: iss-2610040025088395 Refs: iss-2610040847166532 Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
The plain-Terminal spec moved to closed/ in this branch, and the board spec that main gained meanwhile links it as a sibling in open/. Assisted-by: Claude:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plain-Terminal interviews, step 4 of spc-2610030911534855 (itd-2610030810370060): the AI-written interviews on the person's own route, the change guard around each turn, and the spec's close with a remainder.
What changes
The AI-written interviews. The retrospective and the planning interview run in a plain Terminal on the person's own runner route, one dispatch per turn. Each
askreceipt is read throughos.Root(1 MiB, strict decode), sanitised, then checked against the question rules and the asking limits before it is drawn. Answers are recorded throughinterview.Write, anddonewrites the retrospective throughreflect write's own path. The verb refuses with no route, off a terminal without--answers, and on an invalid ask. An answers file replays by ordinal.runner.Dispatcher.Attendedis set only by the interviews.The change guard. The role holds Write, and Edit for planning, with the repository as its working directory. Around every dispatch, abcd reads the repository and stops the interview (exit 1, answers kept, each path named) if anything changed that the role may not change. That covers:
info/, HEAD and the refs, submodule hooks and config;core.hooksPathtarget outside the tree.A link is followed to where it leads, once. Only the turn's own directory is the role's, and two inert paths are exempt:
.DS_Storeand.claude/scheduled_tasks.lock. git's NUL-separated status listing now has one reader ingitutil, which the guard and three earlier callers share.A gate-parity test holds the setup pre-check's copy of the install's early gates in step with the install.
Review
Security reviews and four re-checks ran on this step. Every finding is fixed and pinned by a test watched failing on a scratch copy with the protection removed. Among the earlier findings:
.git/or under a linked hooks directory;The last re-check found a HIGH: a role could register a fake worktree carrying a push receipt, and the pre-push gate then passed. It is captured, fixed and resolved as iss-2610040739124513, and a focused check of that fix returned SHIP. Two smaller holes are resolved in the same way (iss-2610032115023656, wontfix with its reason; iss-2610040639162928, fixed). A slow-walk denial the focused check found is captured as iss-2610040847166532 and carried into the remainder.
The close
On the product thinker's rulings of 2026-10-04 (the decision log, verbatim): "Ship it as partly done", "Add it to the remaining work" and "Add a real run to the remaining work". So spc-2610030911534855 closes with a remainder, and the intent stays planned. The remainder spec spc-2610040847280931 carries four pieces:
Refs: iss-2610032115023656
Refs: iss-2610040025088395
Refs: iss-2610040847166532
Resolves: iss-2610040739124513
Resolves: iss-2610040639162928
Tests
make preflightis clean. The touched packages pass under the pinned toolchain, under-race, and with the CI environment.go vetpasses for darwin and linux.Assisted-by: Claude:claude-opus-5-5