Skip to content

feat: the AI-written interviews in a plain Terminal, with a change guard around every turn - #814

Merged
REPPL merged 31 commits into
mainfrom
feat/terminal-ai-interviews
Oct 4, 2026
Merged

REPPL merged 31 commits into
mainfrom
feat/terminal-ai-interviews

Conversation

@REPPL

@REPPL REPPL commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

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 ask receipt is read through os.Root (1 MiB, strict decode), sanitised, then checked against the question rules and the asking limits before it is drawn. Answers are recorded through interview.Write, and done writes the retrospective through reflect 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.Attended is 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:

    • tracked, untracked and gitignored paths;
    • the local tier, including its push receipts;
    • git's own directory: hooks, config, info/, HEAD and the refs, submodule hooks and config;
    • every worktree's entry, and every listed worktree's push receipts;
    • a core.hooksPath target 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_Store and .claude/scheduled_tasks.lock. git's NUL-separated status listing now has one reader in gitutil, 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:

  • unscoped planning edits;
  • a stale receipt taken as a turn's answer;
  • an interrupt recorded as a runner failure;
  • a planted hook in .git/ or under a linked hooks directory;
  • a forged push receipt.

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:

  • setup's answers-file gap;
  • typed answers in the AI-written interviews;
  • the bound on a followed link;
  • one real interview with the person.

Refs: iss-2610032115023656
Refs: iss-2610040025088395
Refs: iss-2610040847166532
Resolves: iss-2610040739124513
Resolves: iss-2610040639162928

Tests

make preflight is clean. The touched packages pass under the pinned toolchain, under -race, and with the CI environment. go vet passes for darwin and linux.

Assisted-by: Claude:claude-opus-5-5

REPPL added 30 commits October 4, 2026 04:40
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
…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
Brings in #806 (step 3) and #801. The one conflict, in the cli tests'
TestMain, keeps both re-execution hooks: the interview stub runner and the
connect pseudo-terminal child.

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
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
@REPPL
REPPL enabled auto-merge October 4, 2026 09:38
@REPPL
REPPL added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 30aeeb8 Oct 4, 2026
13 of 15 checks passed
@REPPL
REPPL deleted the feat/terminal-ai-interviews branch October 4, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant