# Feature: Maturity Follow-Up

<!-- toc -->
- [1. The rule everything else follows](#1-the-rule-everything-else-follows)
- [2. Interactive: ask at the step, do not halt at it](#2-interactive-ask-at-the-step-do-not-halt-at-it)
- [3. Autopilot: ask on the item, then stop](#3-autopilot-ask-on-the-item-then-stop)
- [4. Resuming into the step, not past it](#4-resuming-into-the-step-not-past-it)
- [5. State](#5-state)
<!-- /toc -->

**Pattern**: the maturity check produces a machine-readable gap list - stable
codes in `blockers[]` and `warnings[]`. A halt on its own leaves the item as
immature as it was found and tells nobody, so this feature turns the list into a
question: asked at the step in an interactive run, posted on the item by an
autopilot run that is allowed to.

Three behaviours, one decision function
(`$HOME/.claude/scripts/maturity-followup.mjs`, pure - no network, no issue API,
no clock unless handed one). Asserted by `smoke-maturity-followup.sh` and
`test/maturity-followup.test.mjs`.

## 1. The rule everything else follows

**An edit is a reason to look again. It is never proof that the gap closed.**

A reply reading "will do later" moves the artifact's timestamp and fixes
nothing. So a changed artifact re-runs the maturity check against the new
content and the CHECK decides. Nothing in this feature infers maturity from the
fact that something moved, and the decision function is handed a freshly scored
`maturity` on every pass for exactly that reason.

The corollary is the second comment. A run that re-comments on every pass turns
an issue into a wall of identical bot text, so:

| Situation | What happens |
|---|---|
| First pass, gaps present | comment once |
| Same gaps, artifact untouched | say nothing |
| Same gaps, artifact edited | say nothing - the re-check already ran and they survived |
| **Different** gaps | comment - a different question is new information |
| No gaps | proceed; development starts |

**Where "have we already asked" comes from.** The item, not our state file. A
new run on the same item - started by hand, or by a client after the parked one
was abandoned - has a fresh `agent-state.json`, so deriving it from state alone
would make that run a first ask. So the comment carries its own gap set on a
last line, `multi-agent gaps: code,code`, and the next pass reads the item's
comments and takes the newest one of ours (`priorFromComments`). `state.maturityFollowup`
is a cache of the same answer for the run that wrote it, never the source.

A comment of ours carrying no gap line - written before v17.6.0, or edited by
hand - reads as "asked, about something we can no longer name": an empty gap set,
which never equals a live one, so the next pass asks again WITH the codes instead
of staying silent forever on an unreadable record.

"Cannot tell whether it moved" (a tracker whose API omits the timestamp, an
unparseable value) resolves to *re-check*, never to *wait*. Folding unknown into
"nothing changed" parks a run forever on a host that never told us anything.

## 2. Interactive: ask at the step, do not halt at it

A blocker asks, at the maturity step, with the gap as the question. The options are real choices and meet the
two-option floor on their own (`picker-contract.md`, "Two options or it is not a
question"):

| Option | What it does |
|---|---|
| Open the item and fix it | halts, prints the item URL, resumes into this same step |
| Continue without it | proceeds, and records WHICH gap was accepted in `state.maturity.accepted[]` |
| Abort | no worktree, no branch, no state file |

`prefs.global.maturityFollowup.askInteractively: false` (default `true`) halts with a
summary instead.

**What an answer here does not do.** An answer typed into a picker improves this
run and leaves the item as immature as it was for the next person. That is a
real cost, not an oversight, and the step says so: after an answer that supplies
missing content, it offers to write that content back to the item - as a
separate, individually approved write, per the standing rule that every Jira
write is approved on its own.

## 3. Autopilot: ask on the item, then stop

`autopilotCommentsOnIssue` (**default `false`**) lets an autopilot run post one
comment on the item asking for what is missing. It is an outward-facing write,
so it carries the same fence as every other one in this pipeline:

- **Off by default.** Nothing posts unless the user turned it on.
- **A question, never a state change.** No transition, no resolution, no
  assignee, no label, no close - ever. The standing rule that this pipeline
  never auto-closes an issue is not relaxed by a feature that writes comments.
- **One comment.** The marker line makes the next pass able to recognise its own
  prior comment; matching on the marker rather than on authorship is what keeps
  that working when the token belongs to a shared service account.
- **No square brackets in the marker or the gap line.** `[text]` is a LINK in
  Jira wiki markup, and this comment is most likely to be posted exactly there,
  so a bracketed marker renders as a broken link to a page nobody created.
- **`Ref:`, never `Closes:`/`Fixes:`/`Resolves:`**, so no platform-side
  automation reads a question as an instruction.
- **Human-facing copy follows `outputLanguage`**, and the gap wording is the
  fetcher's own `maturity.summary` verbatim. Re-deriving those labels here would
  give the project two copies of one table and only one would be maintained.
- **Research comes first.** Under the autopilot runner a run parked here gets a
  research pass before it is left waiting: tracker comments, links, linked
  documents and the repo, checked source by source by `research-gate.mjs`, and
  the maturity check re-run on the verified result
  (`features/research.md`). A question is asked only for what that leaves open.
- **Then it stops.** The run halts on the circuit breaker (`features/autopilot-circuit-breaker.md`),
  which is the sanctioned autopilot pause: state recorded, one actionable line
  printed, waiting for `resume`. Posting a question and continuing on a guess is
  worse than not asking - the guess lands in a branch while the question sits
  unanswered.
- **A later scan does not pick it up again.** The autopilot intake keeps a
  parked item in `awaiting` and never queues it on its own, whatever happens on
  the item meanwhile. It moves only when a
  person answers, through `resume <id> --answer` or a client, and the resumed run
  re-enters this step with the item re-fetched (section 4).

**Not a second readiness reviewer.** `/multi-agent:review-jira` and
`/multi-agent:review-issue` also post a gap list, and they are a different thing: a
human invokes them ON PURPOSE to review an item, with the full readiness rubric
(`readiness-review.md`) behind the verdict. This comment is a side effect of a
development run that could not start, carries only the fetcher's own blocker codes,
and posts at most once. Both obey the same tone contract (`channels/issue-comment.md`):
no AI attribution, `Ref:` never a closing keyword, copy in `outputLanguage`.

**Warnings still auto-continue.** Converting every warning into a halt would
stall queues overnight on items that ran fine yesterday, so blockers are
actionable by default and `prefs.global.maturityFollowup.commentOnWarnings` raises
warnings to the same treatment. Either way the gaps are recorded, so the next pass can compare.

## 4. Resuming into the step, not past it

`/multi-agent:resume` starts from `currentPhase + 1`. A run that halted at the
maturity step has `currentPhase: 0`, so resuming would start at Phase 1 and skip
the check - the halt would be permanent in the one direction that matters.

So resume reads `state.waitingFor` first: when it names a step, the run re-enters
THAT step rather than the next phase. Phase 5's channels pause resumes through
the same field (`phases/phase-5-report.md`).

| `waitingFor` | Re-entry |
|---|---|
| `maturity` | Phase 0, the maturity step, with the item re-fetched. With `state.research.decision: "proceed"` the step re-scores through `research-gate.mjs --recheck` (below) |
| `question`, `pendingQuestion.stepId: phase-0/maturity` | the same step, reading `lastAnswer`: `fix` re-fetches and re-checks, `continue` records the gaps in `maturity.accepted[]`, `abort` stops |
| `user-channels-choice` | Phase 5, the channels menu |
| absent | `currentPhase + 1`, as before |

**After research.** `research-gate.mjs` writes `waitingFor: "maturity"` when the
re-run check passed on the verified findings. The step re-fetches as always, then:

```bash
node "$HOME/.claude/scripts/research-gate.mjs" --state "$STATE_FILE" --recheck --descriptor "$FRESH" --json
```

Exit 0: no blocker once the recorded verified findings are applied to the fresh
item; the printed `description` is the working description and `maturity` the
maturity. Exit 1: the gaps are back (a comment deleted, a status changed), and
the step takes the blocker path above. The edit rule holds: research moves
nothing on the item, and the check still decides.

`waitingFor` is cleared by the write that records the answer. A field that
outlives its question sends every later resume back to the step the user already
answered.

## 5. State

```jsonc
"maturity": {
  "score": 60,                       // null for free-text: nothing to score
  "blockers": ["description_empty"],
  "warnings": [],
  "summary": "...",                  // localized by the fetcher, used verbatim
  "accepted": ["short_description"]  // gaps a human waved through, interactive only
},
"maturityFollowup": {
  "gaps": ["description_empty"],     // sorted + deduplicated, so comparison is stable
  "askedAt": "2026-09-15T11:00:00Z",
  "target": { "kind": "jira", "key": "PROJ-1234", "url": "..." },
  "commentUrl": "..."
}
```

`maturityFollowup` exists only after a comment was posted, and it is a cache: the
authoritative record of what was asked is the comment on the item itself, because
that is the only store the next run can see.
