feature-factory 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # feature-factory
2
2
 
3
- A durable, observed control plane for running a feature from idea to draft PR through a chain of
3
+ A durable, observed control plane for running a feature from idea to pull request through a chain of
4
4
  focused agents, with human approval gates. Host-agnostic, zero dependencies.
5
5
 
6
6
  ```sh
package/WORKFLOW.md CHANGED
@@ -5,9 +5,16 @@ skill by itself. A host integration must ship its own `SKILL.md` and place an ex
5
5
  to that skill as `WORKFLOW.md`.
6
6
 
7
7
  **Where the driver reads this file from, and when.** `factory init` stages an exact copy into the run
8
- directory and returns its path as `workflow`. The driver reads THAT copy, completely, before any state read,
9
- dispatch, gate, or factory command other than `init` itself — admission and the `init` invocation are
10
- specified by the host `SKILL.md`, everything after them here. A host whose agents may read outside the
8
+ directory and returns its path as `workflow`. The driver reads THAT copy, completely, before any
9
+ dispatch, gate, or factory command other than the bounded opening this paragraph names — admission, that
10
+ opening, and the `init` invocation are specified by the host `SKILL.md`, everything after them here. The
11
+ opening is: inspect the two deterministic manifest candidates, and where one exists, qualify it with
12
+ `factory status` and read the staged workflow at `<qualified sandbox_path>/.factory/$R/WORKFLOW.md` --
13
+ status reports `sandbox_path`, not a run directory or a workflow path, so the derivation is stated rather
14
+ than left to be guessed. A fresh run reaches the
15
+ staged copy through `init`; an existing one must never be initialized again, so it reaches the same copy
16
+ through that lookup. Those two candidate reads and that one `status` call are the only state reads
17
+ permitted before this file is in hand. A host whose agents may read outside the
11
18
  workspace may instead read the copy beside its skill; a host that denies such reads must use the staged copy,
12
19
  because the packaged one is unreadable there and a run that depends on it fails on a permission refusal
13
20
  rather than on anything about the work. Either way the bytes are identical, and a driver that cannot read
@@ -88,7 +95,7 @@ repository and the host is inside your trust boundary by construction. What that
88
95
  INTAKE ─▶ [GATE 1: Story] ─▶ RESEARCH + DESIGN ─▶ SPEC ─▶ DECOMPOSE ─▶ [GATE 2: Brief + Plan]
89
96
  ─▶ BUILD (waves of parallel slices; per-slice OBSERVE ▶ REVIEW ▶ serial MERGE)
90
97
  ─▶ INTEGRATE: TEST + VALIDATE (on the merged feature branch)
91
- ─▶ [GATE 3: Pre-PR] ─▶ DRAFT PR
98
+ ─▶ [GATE 3: Pre-PR] ─▶ PR (draft per pr_draft)
92
99
  ```
93
100
 
94
101
  `work-reviewer` runs on **high-risk steps only** — spec, decompose, each slice build, and test — and
@@ -299,8 +306,10 @@ fresh run; an existing run follows these rules solely because its manifest alrea
299
306
  unresolved product, UX, security, or external-policy decision.
300
307
  - **Gate 2 (brief + plan)**: approve only after `work-reviewer` approves both spec and decomposition,
301
308
  every acceptance criterion maps to a slice, and same-wave slices are file-disjoint.
302
- - **Gate 3 (pre-PR)**: approve only on a GO or GO-WITH-NITS validator verdict with `review_ready`
303
- observed evidence for the integrated branch. A NO-GO is a NO-GO.
309
+ - **Gate 3 (pre-PR)**: approve only with `review_ready` observed evidence for the integrated branch, and
310
+ on a GO or GO-WITH-NITS validator verdict **for a multi-slice run**. A NO-GO is a NO-GO. A single-slice
311
+ run skips the validator exactly as Step 5 specifies, so requiring a verdict here would make the one
312
+ case Step 5 exempts unapprovable; if such a run recorded a verdict anyway, it binds.
304
313
  - **Never auto-merge.** Recording the pull request is the last externally publishing side effect an
305
314
  autonomous run may perform. Whether that PR is a draft is the repository's `pr_draft` choice and is
306
315
  independent of the run mode: autonomous does not imply draft, and `pr_draft: false` is a supported
@@ -1171,8 +1180,9 @@ CHECKED_OUT_FEATURE_BRANCH="$(git -C "$INTEGRATION_WORKTREE" symbolic-ref --quie
1171
1180
 
1172
1181
  Require it to equal `FEATURE_BRANCH` and require `HEAD^{commit}` to equal
1173
1182
  `refs/heads/$FEATURE_BRANCH^{commit}` as one 40-character SHA. If no slice is merged, or `.factory.json`
1174
- is absent, preserve the existing progression and output exactly. A present config must validate as the
1175
- four required properties plus optional `verify_timeout_ms` object above before any entry is used.
1183
+ is absent, preserve the existing progression and output exactly. A present config must validate against the
1184
+ repository-configuration schema stated above before any entry is used; that statement is authoritative and
1185
+ is deliberately not restated here, because a restated shape goes stale as the schema gains properties.
1176
1186
 
1177
1187
  With valid config, validate canonical `evidence/test-verifier.json` as untrusted input using the same
1178
1188
  closed schema and derived `review_ready` rules as the CLI. Classify it into exactly four outcomes:
@@ -1406,9 +1416,10 @@ Per slice:
1406
1416
 
1407
1417
  For AC6 and AC7, terminalize means terminate the current `factory slice … merged` CLI invocation and
1408
1418
  its enclosing run-driver invocation after two unavailable executions; it does not mean the irreversible
1409
- factory terminal transition. Clean, unchanged exhaustion leaves durable `status: "running"` and
1410
- `terminal_result: null`, so a later explicit invocation may reconcile the same merge with a fresh local
1411
- budget. Top-level needs-human remains parked while replay safety is false; explicit resume does not bypass the same safety check.
1419
+ factory terminal transition. Clean, unchanged exhaustion leaves durable `status: "running"` with its
1420
+ `terminal_result` unchanged — `null` for a run that has never parked, and the preserved historical result
1421
+ for one continued by explicit resume — so a later explicit invocation may reconcile the same merge with a
1422
+ fresh local budget. Top-level needs-human remains parked while replay safety is false; explicit resume does not bypass the same safety check.
1412
1423
 
1413
1424
  After a clean, unchanged second `unavailable`, stop dispatching and processing `status.next`, and never
1414
1425
  issue another same-SHA replay in this driver invocation. Await every in-flight specialist task. Stop
@@ -1420,7 +1431,11 @@ factory lock "$R" release --session "$SESSION_ID" --repo "$RUN_REPO"
1420
1431
  ```
1421
1432
 
1422
1433
  Run qualified `factory status "$R" --json --repo "$RUN_REPO"` and require valid durable
1423
- `status: "running"`, `terminal_result: null`, and proof that this `SESSION_ID` no longer owns the lock.
1434
+ `status: "running"`, a `terminal_result` unchanged from the one this invocation began with, and proof
1435
+ that this `SESSION_ID` no longer owns the lock. For a run that has never parked that value is `null`; a
1436
+ run continued by explicit resume keeps its historical result by design, since resume preserves
1437
+ `terminal_result` rather than clearing it, so requiring `null` would report every resumed run as a
1438
+ retained-lock error.
1424
1439
  In the uncontended orderly path require `lock: "absent"`. Only after every task and heartbeat is
1425
1440
  quiescent, the owning release succeeds, and qualified status proves those values may the driver report:
1426
1441
 
@@ -1429,7 +1444,7 @@ Run: <R>
1429
1444
  Run repository: <RUN_REPO>
1430
1445
  Outcome: repository-verify-exhausted
1431
1446
  Status: running
1432
- Terminal result: null
1447
+ Terminal result: <unchanged from this invocation's start: null, or the preserved historical result>
1433
1448
  Lock: released
1434
1449
  ```
1435
1450
 
@@ -1721,8 +1736,12 @@ HEAD, a branch name, or an unpersisted variable.
1721
1736
  **before** presenting Gate 3: the gate cannot be approved without it.
1722
1737
 
1723
1738
  On NO-GO, classify each finding against the prior round and find its design-level root cause before
1724
- spending a retry; route the top finding to the owning builder in a fresh slice worktree, or fix in the
1725
- integration branch if it is test-only. A test-only fix there touches test files only — never production
1739
+ spending a retry. A **test-only** finding is fixed in the integration branch under the rules below. A
1740
+ finding in production source has no legal path at this point and must **park top-level needs-human**
1741
+ naming the finding and its root cause: every slice is merged, a merged slice cannot reopen or redispatch,
1742
+ seeding is one-time, and the integration fix is test-only by construction — so "route it to the owning
1743
+ builder in a fresh slice worktree" is an instruction the contract cannot carry out. Parking is the honest
1744
+ outcome and leaves the work recoverable; improvising a reopen is not. A test-only fix there touches test files only — never production
1726
1745
  source, never a privileged control-plane path — preserves the property under test or records why it
1727
1746
  cannot, lands as its own commit rather than folded into a merge, and is disclosed in the PR body naming
1728
1747
  the file and the cause. Respect `max_retries`.
@@ -85,10 +85,15 @@ Then append a machine-readable **claim block** the orchestrator parses (it re-ru
85
85
  "tests": {"cmd": "<the test command you ran>", "exit": 0}, "blockers": []}
86
86
  ```
87
87
 
88
- Use exactly these field names and exactly this `status` vocabulary. The orchestrator feeds this
89
- block to `factory observe --claim`, which compares each field against what it observes itself and
90
- records every disagreement as a review finding. `completed` is the word the evidence uses; any
91
- other spelling reads as a disagreement about status. `tests.exit` must be the real exit code — a
92
- claimed zero against an observed failure is the most important disagreement this catches.
88
+ Use exactly these field names and exactly this `status` vocabulary.
89
+
90
+ **`files_changed` is your report to the reviewer, not a claim the orchestrator reconciles.** Unlike a
91
+ build slice, this stage is observed against the **integrated** worktree from the run's original branch
92
+ point, so the observed diff legitimately contains every merged builder's changes as well as your tests,
93
+ and the workflow's integration observation passes no `--claim`. Listing only your own test files and
94
+ having it compared against that diff would manufacture a `claim_mismatch` on correct work. `completed` is the word the evidence uses; any
95
+ other spelling reads as a disagreement about status. `tests.exit` must be the real exit code: the
96
+ orchestrator re-runs the suite itself, and a claimed zero against an observed failure is the
97
+ disagreement that matters most.
93
98
 
94
99
  Commit test files separately to the worktree branch (`git -C $WT add <tests> && git -C $WT commit -m "<KEY>: tests for <feature>"`). A FAIL is a valid, useful result — report it honestly; do not weaken a test to make it pass.
@@ -36,9 +36,27 @@ Do not delegate, and do not open a fresh repo-wide survey. Keep verification sco
36
36
  ## Reconcile claim vs. observation (the core rule)
37
37
 
38
38
  The producer returns a **claim** (its JSON summary / report). The orchestrator's observed evidence is the **truth**. Your first job is to reconcile them:
39
- - Claim says files changed / tests passed but the observed evidence disagrees → **REJECT** (`claim_mismatch`).
40
- - Observed `review_ready` is false (empty diff, unobserved/failed tests, `diff_observed=false`) → **REJECT**.
39
+ - Claim says files changed / tests passed but the observed evidence disagrees → **REJECT**
40
+ (`claim_mismatch`) — reconciled **against the claim's own subject**. For a build slice the observation
41
+ is that slice's diff, so its file list must match. For `test-verifier` the observation is the whole
42
+ **integrated** diff, which legitimately contains every merged builder's files as well as the tests, so
43
+ extra files there are not a mismatch; what must reconcile is its `tests.exit` against the observed run.
44
+ - **For a build slice or `test-verifier` only:** observed `review_ready` is false (empty diff, unobserved/failed tests, `diff_observed=false`) → **REJECT**.
41
45
  - Never approve on the producer's word alone.
46
+ - **Repair evidence substitutes for ordinary evidence where Step 5 permits it.** After a test-only repair,
47
+ the failed ordinary evidence is deliberately preserved alongside a qualifying repair record, and the
48
+ repair evidence is what stands. Requiring a green `evidence/test-verifier.json` in that situation rejects
49
+ the recovery path the workflow defines. Check the repair record and its evidence, not the superseded
50
+ failure.
51
+
52
+ **This whole section applies only to subjects that have observed evidence.** A planning subject —
53
+ `spec-writer`, `work-decomposer` — produces an artifact under `.factory/$R/artifacts/`, not a worktree
54
+ commit, so it has no diff and no `evidence/<subject>.json`, and the workflow reviews it with a
55
+ `--review-ref` rather than observing it. Its ground truth is the artifact and the cited files, per
56
+ "Review discipline" above. Never reject a planning subject for missing, empty or not-`review_ready`
57
+ evidence: an empty diff is that subject's correct shape, `review_ready` is false for every zero-diff
58
+ observation by construction, and rejecting on it blocks the step permanently rather than asking the
59
+ producer for anything it could supply.
42
60
 
43
61
  ## Class-wide completeness (the anti-drip-feed rule)
44
62
 
@@ -77,10 +95,18 @@ When the subject is a **class-wide** requirement — one that **cannot be establ
77
95
  - Backend: the repo's layering, its projection/read path, its API boundary.
78
96
  - Frontend: the repo's component conventions, binding forms, state approach and design tokens.
79
97
  - Migrations: the repo's filename, author, context, manifest-registration and permission steps.
80
- - No edits to vendored or generated trees. No stray code comments.
98
+ - No **hand**-edits to vendored or generated trees. Regeneration is different and is required: the
99
+ slice that changes the source owns regenerating what derives from it, so a regenerated client or
100
+ type set in that slice's lane is expected output, not a finding.
101
+ - No stray code comments.
81
102
  - **Slice discipline:** the diff stays within the slice's `paths` (out-of-lane edits are a finding).
82
- - The slice's `acceptance` is actually implemented, and the observed tests cover it.
83
- - **Test step (`test-verifier`):** each AC maps to a real assertion that would fail if the behavior broke; no test weakened to pass; the observed command is the suite the plan named and was not narrowed to exclude failures, which is a separate finding from weakening a test; observed test run is green (or honestly WRITTEN-NOT-RUN with a reason).
103
+ - The slice's `acceptance` is actually implemented. This is never waived.
104
+ - The observed tests cover that acceptance — **unless the slice's ratified `test_plan` is empty**,
105
+ which the plan decided at Gate 2 and which the workflow and `deriveReviewReady` both honour. A
106
+ docs-only slice reviewed against tests it was ratified not to have is rejected forever; that
107
+ exemption is a plan decision, not yours to re-open here. It waives **test execution only**: the
108
+ acceptance must still be implemented and the diff must still be observed.
109
+ - **Test step (`test-verifier`):** each AC maps to a real assertion that would fail if the behavior broke; no test weakened to pass; the observed command is the suite the plan named and was not narrowed to exclude failures, which is a separate finding from weakening a test; observed test run is green. **There is no WRITTEN-NOT-RUN waiver for this subject:** the stage exists to run the tests, so the evidence must record an observed run that exited zero. Reporting WRITTEN-NOT-RUN honestly is valid; approving on it is not.
84
110
 
85
111
  ## Security proportionality
86
112
 
@@ -88,7 +114,7 @@ The repository's real trust boundaries stay fully blocking: unauthenticated or a
88
114
 
89
115
  ## Severity
90
116
 
91
- - **BLOCKER** — claim/observation mismatch, `review_ready=false`, an AC unmet or untested, a convention violation a human reviewer would bounce (unguarded prod migration, subtree edit, out-of-lane file), a correctness/security bug.
117
+ - **BLOCKER** — claim/observation mismatch, `review_ready=false` on a subject that has observed evidence, an AC unmet, an AC untested against a non-empty ratified `test_plan`, a convention violation a human reviewer would bounce (unguarded prod migration, subtree edit, out-of-lane file), a correctness/security bug.
92
118
  - **MAJOR** — deviates from brief/conventions in a way that will draw review friction; secondary AC untested.
93
119
  - **MINOR** — nits; safe to proceed.
94
120
 
@@ -126,6 +152,6 @@ Write this structure to the narrative report:
126
152
  1. <the specific change the producer must make>
127
153
  ```
128
154
 
129
- Cite `path:line` for every finding — an unsourced finding is noise. If it's genuinely clean and the evidence is review-ready, APPROVE without manufacturing problems. If evidence is missing when it should exist (a build slice with no observed diff/tests), that itself is a BLOCKER — do not approve unobserved work.
155
+ Cite `path:line` for every finding — an unsourced finding is noise. If it's genuinely clean and the evidence is review-ready, APPROVE without manufacturing problems. If evidence is missing when it should exist (a build slice with no observed diff/tests), that itself is a BLOCKER — do not approve unobserved work. "When it should exist" excludes a planning subject, which has no observed evidence by design. It does **not** excuse a missing diff for a slice whose ratified `test_plan` is empty: that slice still changes files and is still observed; only its test run is waived.
130
156
 
131
157
  Your final response may confirm both file writes, but it must not substitute for either file.
package/bin/factory.js CHANGED
@@ -10,10 +10,10 @@ import { pathToFileURL } from "node:url";
10
10
  import { createHash } from "node:crypto";
11
11
  import { isDeepStrictEqual } from "node:util";
12
12
  import { readFileSync } from "node:fs";
13
- import { nextAction, readRun, readRunUnchecked } from "../state/index.js";
13
+ import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../state/index.js";
14
14
  import { transition } from "../state/transition.js";
15
15
  import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
16
- import { assertPublicationReady, assertReviewBinding, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
16
+ import { assertPublicationReady, assertReviewBinding, isApproving, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
17
17
  import { readRepositoryConfig, RepositoryConfigError } from "../observe/repository-config.js";
18
18
  import { reverifyRepair } from "../observe/repair-reverification.js";
19
19
  import { archiveReviewAttempt } from "../state/review-archive.js";
@@ -26,6 +26,10 @@ import {
26
26
  claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SESSION_LOCK_FILE, SessionLockHeldError,
27
27
  } from "../state/session-lock.js";
28
28
 
29
+ // work-reviewer runs on these and must approve before the step is accepted; the others are not
30
+ // auto-reviewed, so requiring a reference for them would block the workflow's own sequence.
31
+ const REVIEWED_STEPS = Object.freeze(["spec-writer", "work-decomposer", "test-verifier"]);
32
+
29
33
  export const COMMANDS = Object.freeze({
30
34
  init: Object.freeze(["--repo", "--branch", "--worktree", "--pr-base", "--issue", "--issue-key", "--publishing-identity", "--mode", "--max-parallel-slices", "--max-retries", "--now", "--json"]),
31
35
  status: Object.freeze(["--repo", "--json"]),
@@ -972,12 +976,32 @@ const HANDLERS = {
972
976
  pr_draft: run.pr_draft ?? true,
973
977
  lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
974
978
  lock_session: lock.owner?.session ?? null,
975
- gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name]).map((name) => [name, run.gates[name].status])),
976
- steps: run.steps.map((step) => `${step.agent}:${step.status}(${step.attempts})`),
977
- slices: run.slices.map((slice) => `${slice.id}:${slice.status}(${slice.attempts})`),
978
- validator: run.validator?.verdict ?? null,
979
+ // The whole gate record, not just its status. `at` and `artifact` were dropped here while sitting
980
+ // intact in `run.json` -- and `at` is what tells a controller whether "approved" happened a minute
981
+ // ago or three hours ago, which is most of what "is this run stuck" means.
982
+ gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name])
983
+ .map((name) => [name, { status: run.gates[name].status, at: run.gates[name].at ?? null,
984
+ artifact: run.gates[name].artifact ?? null, reviewed_head: run.gates[name].reviewed_head ?? null }])),
985
+ // Structured, not `${agent}:${status}(${attempts})`. Attempts are the field a controller reads to
986
+ // decide whether an attempt was consumed, and reaching them meant regexing a display string out of
987
+ // a JSON contract. Nothing in the suite asserted the string form, so it was a public shape with no
988
+ // coverage -- which is how a rendering artifact survived in a machine-readable payload. The content
989
+ // is unchanged; only the shape is. Breaking for anyone parsing the strings, which is safe here
990
+ // because an exact-match `FACTORY_VERSION` pin already forces consumers to move deliberately.
991
+ steps: run.steps.map((step) => ({ agent: step.agent, status: step.status, attempts: step.attempts })),
992
+ slices: run.slices.map((slice) => ({ id: slice.id, status: slice.status, attempts: slice.attempts })),
993
+ // Same narrowing: the record carries `report`, `reviewed_head` and `loops`, and only the verdict
994
+ // came out. `loops` says whether validation is converging; `reviewed_head` says what it judged.
995
+ validator: run.validator
996
+ ? { verdict: run.validator.verdict, report: run.validator.report ?? null,
997
+ reviewed_head: run.validator.reviewed_head ?? null, loops: run.validator.loops ?? null }
998
+ : null,
979
999
  pr_url: run.pr_url,
980
1000
  terminal_result: run.terminal_result,
1001
+ // Both, deliberately. `next_action` is the machine answer; `next` is its rendering, derived from the
1002
+ // same record by one formatter so they cannot drift, and kept because the driver contract, the
1003
+ // sidebar and a lot of prose name `next: gate:story`.
1004
+ next_action: nextActionRecord(run),
981
1005
  next: nextAction(run),
982
1006
  });
983
1007
  },
@@ -1052,6 +1076,12 @@ const HANDLERS = {
1052
1076
  status: decision,
1053
1077
  at: decision === "pending" ? null : at,
1054
1078
  artifact: flags.artifact ?? state.gates[name]?.artifact ?? null,
1079
+ // What this approval judged. The reobserver above already observes the integration head to
1080
+ // prove readiness; recording it is what lets publication tell a fresh approval from a stale
1081
+ // one, which is the whole difference between re-running tests and re-approving.
1082
+ reviewed_head: name === "pre_pr" && decision === "approved"
1083
+ ? integrationHead(repo, state).commit
1084
+ : state.gates[name]?.reviewed_head ?? null,
1055
1085
  },
1056
1086
  },
1057
1087
  ...(name === "brief" ? { plan_digest: briefDigestFor(decision, state, runDir) } : {}),
@@ -1065,6 +1095,38 @@ const HANDLERS = {
1065
1095
  if (!STEP_STATUSES.includes(status)) throw new CliError(`status must be one of ${STEP_STATUSES.join(" | ")}`);
1066
1096
  const runDir = runDirFor(flags, runId);
1067
1097
  assertRunNotParked(runDir, "step");
1098
+ // The reference was stored and never read, so `accepted` was recorded against a missing review file,
1099
+ // a REJECT with blocking fixes, and an approval naming a commit that does not exist -- all three
1100
+ // reproduced through this CLI. That made the enforcement the README claims, and the workflow's
1101
+ // "must APPROVE before you accept that step", instruction rather than fact. A reviewed step now
1102
+ // consumes its review the way a slice does, minus the head binding: a planning subject's output is
1103
+ // an artifact in the control plane, not a commit, so there is no head for the review to name.
1104
+ const priorStep = readRunUnchecked(runDir).run?.steps?.find((step) => step.agent === agent) ?? null;
1105
+ const reviewedRef = flags.reviewRef ?? priorStep?.review_ref ?? null;
1106
+ if (status === "accepted" && REVIEWED_STEPS.includes(agent)) {
1107
+ if (!reviewedRef) throw new CliError(`step '${agent}' cannot be accepted without --review-ref; work-reviewer must approve it first`);
1108
+ const review = readReview(runDir, reviewedRef);
1109
+ if (review.subject !== agent) throw new CliError(`review '${reviewedRef}' approved '${review.subject}', not '${agent}'`);
1110
+ if (!isApproving(review.verdict)) throw new CliError(`review '${reviewedRef}' verdict is ${review.verdict}, not an approval`);
1111
+ // The attempt, or the reference fallback silently re-consumes the previous round's approval: accept
1112
+ // attempt 1, record `running --attempts 2`, then accept again with no `--review-ref` and the stored
1113
+ // attempt-1 review approves attempt 2. Reproduced through the CLI. The fallback stays -- re-passing
1114
+ // an unchanged reference is ordinary -- but it must still be an approval OF THIS attempt.
1115
+ const attempt = flags.attempts === undefined ? priorStep?.attempts ?? 1 : integer(flags.attempts, 1, "--attempts");
1116
+ if (review.attempt !== attempt) {
1117
+ throw new CliError(`review '${reviewedRef}' is for attempt ${review.attempt}, step is at attempt ${attempt}`);
1118
+ }
1119
+ // A planning subject has no commit to bind to, which is why this check omits the head that a slice
1120
+ // merge requires. `test-verifier` is not a planning subject: it judges the integrated branch, so its
1121
+ // review names a commit and that commit must be the head now. Without this it inherited the planning
1122
+ // exemption and a review naming a nonexistent commit was accepted. Caught in review.
1123
+ if (agent === "test-verifier") {
1124
+ const head = integrationHead(resolve(flags.repo ?? process.cwd()), readRun(runDir)).commit;
1125
+ if (review.reviewed_commit !== head) {
1126
+ throw new CliError(`review '${reviewedRef}' approved ${String(review.reviewed_commit).slice(0, 12)} but the integration head is ${String(head).slice(0, 12)}`);
1127
+ }
1128
+ }
1129
+ }
1068
1130
  const at = stamp(flags);
1069
1131
  const next = await transition(runDir, {
1070
1132
  participants: [{ familyId: "steps", mode: "record" }],
@@ -1172,7 +1234,8 @@ const HANDLERS = {
1172
1234
  });
1173
1235
  if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
1174
1236
  return emit(flags, {
1175
- run_id: runId, status: next.status, terminal_result: next.terminal_result, next: nextAction(next),
1237
+ run_id: runId, status: next.status, terminal_result: next.terminal_result,
1238
+ next_action: nextActionRecord(next), next: nextAction(next),
1176
1239
  });
1177
1240
  },
1178
1241
  };
package/core/contracts.js CHANGED
@@ -10,6 +10,10 @@
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { GATE_NAMES, GATE_STATUSES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES } from "../state/schema.js";
12
12
 
13
+ // The steps whose output the plan is derived from. A revision to one of these after seeding would leave
14
+ // the run describing a decomposition its slices were not built from.
15
+ const PLANNING_STEPS = Object.freeze(["spec-writer", "work-decomposer"]);
16
+
13
17
  const TERMINAL_MODES = new Set(["terminalize"]);
14
18
 
15
19
  // The core hands each contract the observer the caller registered; it does not call it.
@@ -194,8 +198,30 @@ const steps = contract({
194
198
  }
195
199
  if (step.attempts < prior.attempts) throw new Error(`step '${step.agent}' attempts cannot decrease`);
196
200
  if (step.attempts > prior.attempts + 1) throw new Error(`step '${step.agent}' attempts cannot skip`);
197
- if (prior.status === "accepted" && step.status !== "accepted") {
198
- throw new Error(`step '${step.agent}' is already accepted`);
201
+ // An accepted step was frozen in every direction except accepted -> accepted, so a Gate 2 revision
202
+ // could record its success and never its rejection: the reviewer's REJECT on the revised artifact
203
+ // had no legal transition. Reopening is a REVISION, which is narrower than "any raised attempt" --
204
+ // the first version of this allowed a raised attempt alone, and that reopened settled planning work
205
+ // after the plan was seeded and reopened steps on completed, blocked and partial runs, none of which
206
+ // any lifecycle asks for. Caught in review, reproduced through the CLI.
207
+ // Any departure from the settled row is a revision -- including accepted -> accepted at a higher
208
+ // attempt, which is what a driver recording only the successful final result produces. Scoping this
209
+ // to a status change let that path skip every restriction below, on a terminal run included. Exact
210
+ // same-attempt re-acceptance stays legal, because that is what a resumed driver re-records.
211
+ const revises = prior.status === "accepted" && (step.status !== "accepted" || step.attempts > prior.attempts);
212
+ if (revises) {
213
+ if (step.status !== "accepted" && step.attempts === prior.attempts) {
214
+ throw new Error(`step '${step.agent}' is already accepted; a revision must raise --attempts`);
215
+ }
216
+ if (TERMINAL_STATUSES.includes(candidate.status)) {
217
+ throw new Error(`step '${step.agent}' cannot reopen on a ${candidate.status} run`);
218
+ }
219
+ // A planning revision belongs before the plan is acted on. Once slices are seeded, the decomposition
220
+ // the step produced is what every slice was derived from, so reopening it here changes nothing that
221
+ // has already been built and leaves the run describing a plan it did not follow.
222
+ if (PLANNING_STEPS.includes(step.agent) && (candidate.slices ?? []).length > 0) {
223
+ throw new Error(`step '${step.agent}' cannot reopen after slices are seeded`);
224
+ }
199
225
  }
200
226
  if (!STEP_STATUSES.includes(step.status)) throw new Error(`step '${step.agent}' status is invalid`);
201
227
  }
package/observe/review.js CHANGED
@@ -281,6 +281,23 @@ export function assertPublicationReady({ runDir, state, runId, repo, observeHead
281
281
  if (validator && head !== validator.reviewed_head) {
282
282
  refuse(`the validator judged ${String(validator.reviewed_head).slice(0, 12)} but the integration head is ${head.slice(0, 12)}`);
283
283
  }
284
+ // Without a validator nothing else binds the approval to a commit, and a single-slice run skips the
285
+ // validator by design -- so approving Gate 3 at A, committing B and re-observing tests at B published
286
+ // under the older approval. Reproduced through the CLI. A gate record written before this field existed
287
+ // cannot be bound and is left to the contract's instruction rather than refused retroactively.
288
+ const approvedHead = state.gates?.pre_pr?.reviewed_head ?? null;
289
+ if (!validator && approvedHead && head !== approvedHead) {
290
+ refuse(`Gate 3 approved ${String(approvedHead).slice(0, 12)} but the integration head is ${head.slice(0, 12)}; re-approve the gate for the current head`);
291
+ }
292
+ // Publication read gates, slices, evidence and the validator, and never the step rows -- so a verifier
293
+ // revision recorded AFTER Gate 3 was approved did not reach it: accept the verifier at attempt 1,
294
+ // approve the gate, then record a genuine REJECT at attempt 2, and publication still succeeded under
295
+ // the older approval. Reproduced through the CLI. Permitting verifier revisions is what made this
296
+ // reachable, so the approval rule has to follow: the verifier's own row must be settled as accepted.
297
+ const verifier = (state.steps ?? []).find((step) => step.agent === "test-verifier") ?? null;
298
+ if (verifier && verifier.status !== "accepted") {
299
+ refuse(`test-verifier is ${verifier.status} at attempt ${verifier.attempts}; accept its resolved revision before publishing`);
300
+ }
284
301
 
285
302
  let repair;
286
303
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/state/index.js CHANGED
@@ -28,11 +28,11 @@ export function readRunUnchecked(runDir) {
28
28
 
29
29
  function nextSliceAction(slices) {
30
30
  const blockedSlice = slices.find((slice) => slice.status === "blocked");
31
- if (blockedSlice) return `blocked-slice:${blockedSlice.id}`;
31
+ if (blockedSlice) return { kind: "blocked-slice", subject: blockedSlice.id };
32
32
  const activeSlice = slices.find((slice) => ["running", "review"].includes(slice.status));
33
- if (activeSlice) return `observe-slice:${activeSlice.id}`;
33
+ if (activeSlice) return { kind: "observe-slice", subject: activeSlice.id };
34
34
  const pendingSlice = slices.find((slice) => slice.status === "pending");
35
- return pendingSlice ? `dispatch-slice:${pendingSlice.id}` : undefined;
35
+ return pendingSlice ? { kind: "dispatch-slice", subject: pendingSlice.id } : undefined;
36
36
  }
37
37
 
38
38
  // The single answer to "what happens next". Both `factory status` and the opencode
@@ -42,23 +42,35 @@ function nextSliceAction(slices) {
42
42
  // Resume: the first thing a returning session needs to know. Preserves the inherited
43
43
  // rules: a pending gate re-presents, a running slice re-observes, an
44
44
  // unaccepted step re-runs.
45
- export function nextAction(run) {
46
- if (["completed", "partial", "blocked"].includes(run.status)) return `terminal:${run.status}`;
45
+ // `{kind, subject}`, because the answer is two facts and a consumer that must split a string on `:` to
46
+ // recover them is parsing a rendering. `subject` is null for the kinds that name no target.
47
+ export function nextActionRecord(run) {
48
+ if (["completed", "partial", "blocked"].includes(run.status)) return { kind: "terminal", subject: run.status };
47
49
  const openStep = run.steps.find((step) => step.status !== "accepted");
50
+ const stepAction = openStep ? { kind: "step", subject: openStep.agent } : null;
48
51
  const sliceAction = nextSliceAction(run.slices);
49
52
  for (const name of GATE_NAMES) {
50
53
  const gate = run.gates[name];
51
54
  // `pending` waits on a human; absent means the phase has not been reached, which is
52
55
  // still not "done". But naming an absent gate while an agent is mid-round reads as
53
56
  // "waiting on you" — so existing slice or step work is named instead.
54
- if (gate === undefined) return sliceAction ?? (openStep ? `step:${openStep.agent}` : `gate:${name}`);
55
- if (gate.status === "pending") return `gate:${name}`;
56
- if (gate.status === "stop") return `stopped-at-gate:${name}`;
57
- if (gate.status === "changes") return `changes-at-gate:${name}`;
58
- if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return "seed-slices";
57
+ if (gate === undefined) return sliceAction ?? stepAction ?? { kind: "gate", subject: name };
58
+ if (gate.status === "pending") return { kind: "gate", subject: name };
59
+ if (gate.status === "stop") return { kind: "stopped-at-gate", subject: name };
60
+ if (gate.status === "changes") return { kind: "changes-at-gate", subject: name };
61
+ if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return { kind: "seed-slices", subject: null };
59
62
  }
60
63
  if (sliceAction) return sliceAction;
61
- if (openStep) return `step:${openStep.agent}`;
62
- if (!run.pr_url) return "pr";
63
- return "complete";
64
+ if (stepAction) return stepAction;
65
+ if (!run.pr_url) return { kind: "pr", subject: null };
66
+ return { kind: "complete", subject: null };
67
+ }
68
+
69
+ // The string form is DERIVED from the record rather than computed beside it, so the two cannot disagree.
70
+ // Retained because the driver contract, the sidebar and a great deal of prose all name `next: gate:story`,
71
+ // and because a human-facing label is a fair thing for a CLI to keep -- as long as it is a projection of
72
+ // the structured answer and not a second implementation of it.
73
+ export function nextAction(run) {
74
+ const { kind, subject } = nextActionRecord(run);
75
+ return subject === null ? kind : `${kind}:${subject}`;
64
76
  }
package/state/schema.js CHANGED
@@ -31,7 +31,12 @@ export const MODES = Object.freeze(["interactive", "headless", "autonomous"]);
31
31
 
32
32
  export const GATE_NAMES = Object.freeze(["story", "brief", "pre_pr"]);
33
33
  export const GATE_STATUSES = Object.freeze(["pending", "approved", "changes", "stop"]);
34
- export const GATE_KEYS = Object.freeze(["status", "at", "artifact"]);
34
+ // `reviewed_head` is the integration head a `pre_pr` approval judged. Without it nothing bound a human
35
+ // approval to a commit, so on a single-slice run -- which skips the validator that carries that binding
36
+ // for multi-slice runs -- approving at A, committing B and re-observing tests at B published under the
37
+ // older approval. Fresh evidence is not fresh approval. Optional, because every other gate has no head
38
+ // to name and a manifest written before this field exists stays valid.
39
+ export const GATE_KEYS = Object.freeze(["status", "at", "artifact", "reviewed_head"]);
35
40
 
36
41
  export const STEP_STATUSES = Object.freeze(["running", "accepted", "rejected", "blocked"]);
37
42
  export const STEP_KEYS = Object.freeze(["agent", "status", "attempts", "review_ref", "evidence_ref"]);
@@ -143,6 +148,7 @@ function gates(errors, value) {
143
148
  if (gate === undefined) continue;
144
149
  const path = `run.gates.${name}`;
145
150
  if (!object(errors, gate, path, GATE_KEYS)) continue;
151
+ if (gate.reviewed_head !== null && gate.reviewed_head !== undefined) optionalPattern(errors, gate, "reviewed_head", SHA, path);
146
152
  enumValue(errors, gate, "status", GATE_STATUSES, path);
147
153
  if (gate.at !== null) optionalPattern(errors, gate, "at", ISO, path);
148
154
  if (gate.artifact !== undefined && gate.artifact !== null) optionalString(errors, gate, "artifact", path);