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 +1 -1
- package/WORKFLOW.md +34 -15
- package/agents/test-verifier.md +10 -5
- package/agents/work-reviewer.md +33 -7
- package/bin/factory.js +70 -7
- package/core/contracts.js +28 -2
- package/observe/review.js +17 -0
- package/package.json +1 -1
- package/state/index.js +25 -13
- package/state/schema.js +7 -1
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
|
|
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
|
|
9
|
-
dispatch, gate, or factory command other than
|
|
10
|
-
specified by the host `SKILL.md`, everything after them here.
|
|
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] ─▶
|
|
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
|
|
303
|
-
|
|
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
|
|
1175
|
-
|
|
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"`
|
|
1410
|
-
`terminal_result
|
|
1411
|
-
|
|
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
|
|
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
|
|
1725
|
-
|
|
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`.
|
package/agents/test-verifier.md
CHANGED
|
@@ -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.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
package/agents/work-reviewer.md
CHANGED
|
@@ -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**
|
|
40
|
-
|
|
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.
|
|
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
|
|
83
|
-
-
|
|
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
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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,
|
|
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
|
-
|
|
198
|
-
|
|
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
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
|
|
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
|
|
33
|
+
if (activeSlice) return { kind: "observe-slice", subject: activeSlice.id };
|
|
34
34
|
const pendingSlice = slices.find((slice) => slice.status === "pending");
|
|
35
|
-
return pendingSlice ?
|
|
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
|
-
|
|
46
|
-
|
|
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 ??
|
|
55
|
-
if (gate.status === "pending") return
|
|
56
|
-
if (gate.status === "stop") return
|
|
57
|
-
if (gate.status === "changes") return
|
|
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 (
|
|
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
|
-
|
|
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);
|