feature-factory 0.8.8 → 0.9.0
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 +89 -44
- package/agents/backend-builder.md +2 -2
- package/agents/frontend-builder.md +1 -1
- package/agents/implementation-validator.md +1 -1
- package/agents/story-writer.md +3 -3
- package/agents/test-verifier.md +10 -5
- package/agents/work-decomposer.md +5 -10
- package/agents/work-reviewer.md +33 -7
- package/bin/factory.js +94 -4
- package/core/contracts.js +52 -2
- package/observe/review.js +17 -0
- package/package.json +1 -1
- package/state/schema.js +22 -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
|
|
@@ -184,8 +191,28 @@ left by an earlier park: neither is evidence for this park. Publishing again is
|
|
|
184
191
|
|
|
185
192
|
For top-level needs-human, status exposes the durable next action, but no command may execute it before explicit factory resume.
|
|
186
193
|
Retain the sandbox for top-level needs-human while parked, then explicitly resume it after the external fix.
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
**An operator answers a parked run with
|
|
195
|
+
`factory decide "$R" --text "<decision>" --session "$SESSION_ID" --repo "$RUN_REPO"`.**
|
|
196
|
+
The text is appended to an immutable cumulative artifact named
|
|
197
|
+
`artifacts/operator-decisions-<sha256hex>.md`. The manifest's `operator_decision.artifact` is the
|
|
198
|
+
authoritative pointer, and its digest identifies only the recorded bytes. Failed publication may leave
|
|
199
|
+
an unreferenced file; ignore it. Recording a decision does not resume the run.
|
|
200
|
+
|
|
201
|
+
When qualified status reports a non-null `operator_decision`, read the artifact at that pointer in full
|
|
202
|
+
before continuing work on resume. Apply the decision to the permitted choices it names, and report how
|
|
203
|
+
it was applied at the next gate or park. It cannot change ratified paths, `test_plan`, gates, or safety
|
|
204
|
+
authority; path changes require the separate `amend-paths` procedure. If it names no clear permitted
|
|
205
|
+
course of action, ask rather than guess. Recording is enforced; reading and applying are instructions.
|
|
206
|
+
The digest identifies recorded bytes, not proof that the decision was applied.
|
|
207
|
+
|
|
208
|
+
The resume command refreshes the staged `WORKFLOW.md` from the current packaged contract before
|
|
209
|
+
unparking; a copy failure leaves the run parked. After successful resume, read that staged workflow in
|
|
210
|
+
full as part of verifying order 7, before reconciliation, dispatch, or applying decisions. Preserve the
|
|
211
|
+
publishing-identity guard before order 8. For a driver still following an older staged contract, the
|
|
212
|
+
operator may instruct this updated resume sequence, including reading the recorded decision and the
|
|
213
|
+
current no-default-code-ceiling rule. This preserves run state and requires no migration or reset.
|
|
214
|
+
A park that asks a question the decision cannot answer -- one that changes the request itself, so the
|
|
215
|
+
story or brief would have to be regenerated -- is still not fixed by resuming. Resume continues from the existing manifest and
|
|
189
216
|
`status.next`; it does not re-resolve the issue, re-read `ISSUE_PAYLOAD`, or regenerate the story or
|
|
190
217
|
brief, so an edited issue body cannot reach the artifacts a retained run will keep using. The supported
|
|
191
218
|
route is: record the decision in the issue body, then have the operator remove the retained sandbox
|
|
@@ -196,8 +223,9 @@ run, and `factory init` refuses while either manifest candidate exists, so a rel
|
|
|
196
223
|
reselects the parked run instead of replacing it. OPERATING.md carries the command and its cost --
|
|
197
224
|
everything held only in that sandbox is lost, including merged slices whose branches were never pushed,
|
|
198
225
|
so push anything worth keeping first.
|
|
199
|
-
Resume is for external causes -- a timeout, an outage, credentials, an unclean tree --
|
|
200
|
-
|
|
226
|
+
Resume is for external causes -- a timeout, an outage, credentials, an unclean tree -- or recorded
|
|
227
|
+
operator decisions consistent with the approved scope and ratified plan. Changed requirements follow the
|
|
228
|
+
replacement-run route above.
|
|
201
229
|
State that route in the park reason, because a decision recorded only in a host session or a sandbox
|
|
202
230
|
artifact is lost with that sandbox, and the replacement run asks the same question again.
|
|
203
231
|
|
|
@@ -299,8 +327,10 @@ fresh run; an existing run follows these rules solely because its manifest alrea
|
|
|
299
327
|
unresolved product, UX, security, or external-policy decision.
|
|
300
328
|
- **Gate 2 (brief + plan)**: approve only after `work-reviewer` approves both spec and decomposition,
|
|
301
329
|
every acceptance criterion maps to a slice, and same-wave slices are file-disjoint.
|
|
302
|
-
- **Gate 3 (pre-PR)**: approve only
|
|
303
|
-
|
|
330
|
+
- **Gate 3 (pre-PR)**: approve only with `review_ready` observed evidence for the integrated branch, and
|
|
331
|
+
on a GO or GO-WITH-NITS validator verdict **for a multi-slice run**. A NO-GO is a NO-GO. A single-slice
|
|
332
|
+
run skips the validator exactly as Step 5 specifies, so requiring a verdict here would make the one
|
|
333
|
+
case Step 5 exempts unapprovable; if such a run recorded a verdict anyway, it binds.
|
|
304
334
|
- **Never auto-merge.** Recording the pull request is the last externally publishing side effect an
|
|
305
335
|
autonomous run may perform. Whether that PR is a draft is the repository's `pr_draft` choice and is
|
|
306
336
|
independent of the run mode: autonomous does not imply draft, and `pr_draft: false` is a supported
|
|
@@ -477,8 +507,8 @@ tracker URL to select a run declares a `resolve` command recognizing those forms
|
|
|
477
507
|
payload above. Recognition belongs to the declaration for the same reason fetching does: deciding that a
|
|
478
508
|
bare integer is a reference, rather than a feature description, is repository-specific.
|
|
479
509
|
|
|
480
|
-
|
|
481
|
-
|
|
510
|
+
For example, a repository may declare a resolver in `.factory.json` that maps `205`, `#205`, and its
|
|
511
|
+
canonical issue URL to run `205`. These forms work only through that declaration, not built-in behavior.
|
|
482
512
|
|
|
483
513
|
#### Resolver and repository verification boundaries
|
|
484
514
|
|
|
@@ -583,7 +613,7 @@ Resume order 3 — complete the existing effective-push proof.
|
|
|
583
613
|
Resume order 4 — accept the feature branch only after existing reflog/provenance, branch/worktree binding, seed ancestry, cleanliness/recovery, and operator exact-ref rechecks pass in their current order.
|
|
584
614
|
Resume order 5 — immediately before claiming, rerun the final operator exact-ref-absent guard.
|
|
585
615
|
Resume order 6 — claim with the current host session or perform a justified existing steal, then verify qualified status still shows this fresh owner and the parked result originally observed.
|
|
586
|
-
Resume order 7 — invoke explicit factory resume with the verified owning session, then verify running status, unchanged historical terminal result, real next action, and the same fresh owner.
|
|
616
|
+
Resume order 7 — invoke explicit factory resume with the verified owning session, then verify running status, unchanged historical terminal result, real next action, and the same fresh owner; read the refreshed staged WORKFLOW.md in full as part of this verification.
|
|
587
617
|
Resume order 8 — run only existing post-lock reconciliation for an already-recorded merge, its evidence, and repository verification.
|
|
588
618
|
Resume order 9 — continue solely from the newly qualified status.next.
|
|
589
619
|
|
|
@@ -591,6 +621,14 @@ For configured order 7, the CLI binds the exact raw `run.json` bytes, the valida
|
|
|
591
621
|
|
|
592
622
|
A clean zero records the command and exit `0`, advances `updated_at`, and changes status to `running` while preserving progress and the historical terminal result. An ordinary failure with intact bindings records the exact command and integer or `null` result, advances `updated_at`, remains `needs-human`, preserves progress and the historical result, and refuses; a later explicit resume reruns bootstrap. Changed or malformed manifest bytes, or an absent, stale, or different owner, are binding loss rather than ordinary failure: preserve current bytes and ownership, add no bootstrap evidence, and do not unpark.
|
|
593
623
|
|
|
624
|
+
When the operator supplies a decision, optionally use the qualified decision command above after order 6 verifies the fresh
|
|
625
|
+
owner and unchanged parked result, before order 7's explicit resume. Re-read qualified status and the
|
|
626
|
+
manifest pointer; require the same fresh owner, parked status, original result, and the intended recorded
|
|
627
|
+
bytes and digest. A refusal or mismatch stops recovery. This action does not replace `amend-paths` or
|
|
628
|
+
resume. If staying parked after a decision or amendment, refresh the control-plane snapshot using the
|
|
629
|
+
existing *Parked control-plane snapshot* procedure and report its path or failure; neither command
|
|
630
|
+
archives the updated plane automatically.
|
|
631
|
+
|
|
594
632
|
When the parked cause is an insufficient ownership declaration for an existing unmerged slice, the
|
|
595
633
|
operator may insert exactly one optional action after order 6 has verified the fresh exact owner and
|
|
596
634
|
unchanged parked result, and before the unchanged explicit resume in order 7:
|
|
@@ -616,10 +654,19 @@ When the run reports a nonempty `publishing_identity`, the mandatory guard below
|
|
|
616
654
|
boundary between completion of resume order 7 and the first operation in resume order 8. Nothing may
|
|
617
655
|
intervene between the verified running/same-owner result and that guard, or between a successful guard
|
|
618
656
|
and reconciliation. A pre-0.8.0 manifest reporting `null` preserves the nine orders without adding an operation.
|
|
657
|
+
The refreshed workflow read belongs to order 7 verification, before this boundary.
|
|
619
658
|
|
|
620
659
|
For order 1 require the intended run ID, a valid manifest, recorded branch and mode, current parked status, and the original terminal result. Order 2 stays after selection and containment and before effective-push proof. Order 3 never absorbs containment, binding, or the post-selection exact-ref guard. During order 4 preserve every existing exact-ref recheck and the stated provenance sequence. No unrelated observation or effect occurs between order 5 and claim or justified steal. Order 6 requires `lock_session === SESSION_ID`, a fresh lock, unchanged parked status, and a terminal result deeply equal to the one first observed. Invoke `factory resume "$R" --session "$SESSION_ID" --repo "$RUN_REPO"` for order 7 — the same session order 6 just verified as the fresh owner — then require that owner unchanged. Resume refuses without it, and refuses a lock that is absent, stale, or held by anyone else. Order 8 may replay only the existing recorded-merge reconciliation path and must not move pre-lock proofs across the lock boundary. Order 9 never uses the pre-resume observation or the stop reason.
|
|
621
660
|
|
|
622
|
-
If resume refuses after claim or the run later reparks, quiesce builders, tools, specialist tasks, and
|
|
661
|
+
If resume refuses after claim or the run later reparks, quiesce builders, tools, specialist tasks, and
|
|
662
|
+
heartbeat loops. Qualify the intended retained run again before reporting the stop. If it is still parked
|
|
663
|
+
with the same fresh owning session and the expected historical result, republish its current control
|
|
664
|
+
plane using *Parked control-plane snapshot*, then report the verified snapshot path or the publication
|
|
665
|
+
failure. A refreshed workflow or recorded bootstrap failure can make the previous snapshot stale.
|
|
666
|
+
Do not exclude `WORKFLOW.md` from verification, or claim that a stale snapshot is current. If state or
|
|
667
|
+
ownership cannot be qualified, do not publish a snapshot; report the qualification failure instead.
|
|
668
|
+
Release only the same owning session, and require qualified status to show an absent lock and null owner
|
|
669
|
+
before another session begins; never release a different owner's lock.
|
|
623
670
|
|
|
624
671
|
Before requesting a fresh run, inspect only the two deterministic manifest candidates described by the
|
|
625
672
|
CLI contract: the legacy candidate under `O/.factory/R` and the sandbox candidate under
|
|
@@ -1171,8 +1218,9 @@ CHECKED_OUT_FEATURE_BRANCH="$(git -C "$INTEGRATION_WORKTREE" symbolic-ref --quie
|
|
|
1171
1218
|
|
|
1172
1219
|
Require it to equal `FEATURE_BRANCH` and require `HEAD^{commit}` to equal
|
|
1173
1220
|
`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
|
-
|
|
1221
|
+
is absent, preserve the existing progression and output exactly. A present config must validate against the
|
|
1222
|
+
repository-configuration schema stated above before any entry is used; that statement is authoritative and
|
|
1223
|
+
is deliberately not restated here, because a restated shape goes stale as the schema gains properties.
|
|
1176
1224
|
|
|
1177
1225
|
With valid config, validate canonical `evidence/test-verifier.json` as untrusted input using the same
|
|
1178
1226
|
closed schema and derived `review_ready` rules as the CLI. Classify it into exactly four outcomes:
|
|
@@ -1406,9 +1454,10 @@ Per slice:
|
|
|
1406
1454
|
|
|
1407
1455
|
For AC6 and AC7, terminalize means terminate the current `factory slice … merged` CLI invocation and
|
|
1408
1456
|
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
|
-
|
|
1457
|
+
factory terminal transition. Clean, unchanged exhaustion leaves durable `status: "running"` with its
|
|
1458
|
+
`terminal_result` unchanged — `null` for a run that has never parked, and the preserved historical result
|
|
1459
|
+
for one continued by explicit resume — so a later explicit invocation may reconcile the same merge with a
|
|
1460
|
+
fresh local budget. Top-level needs-human remains parked while replay safety is false; explicit resume does not bypass the same safety check.
|
|
1412
1461
|
|
|
1413
1462
|
After a clean, unchanged second `unavailable`, stop dispatching and processing `status.next`, and never
|
|
1414
1463
|
issue another same-SHA replay in this driver invocation. Await every in-flight specialist task. Stop
|
|
@@ -1420,7 +1469,11 @@ factory lock "$R" release --session "$SESSION_ID" --repo "$RUN_REPO"
|
|
|
1420
1469
|
```
|
|
1421
1470
|
|
|
1422
1471
|
Run qualified `factory status "$R" --json --repo "$RUN_REPO"` and require valid durable
|
|
1423
|
-
`status: "running"`, `terminal_result
|
|
1472
|
+
`status: "running"`, a `terminal_result` unchanged from the one this invocation began with, and proof
|
|
1473
|
+
that this `SESSION_ID` no longer owns the lock. For a run that has never parked that value is `null`; a
|
|
1474
|
+
run continued by explicit resume keeps its historical result by design, since resume preserves
|
|
1475
|
+
`terminal_result` rather than clearing it, so requiring `null` would report every resumed run as a
|
|
1476
|
+
retained-lock error.
|
|
1424
1477
|
In the uncontended orderly path require `lock: "absent"`. Only after every task and heartbeat is
|
|
1425
1478
|
quiescent, the owning release succeeds, and qualified status proves those values may the driver report:
|
|
1426
1479
|
|
|
@@ -1429,7 +1482,7 @@ Run: <R>
|
|
|
1429
1482
|
Run repository: <RUN_REPO>
|
|
1430
1483
|
Outcome: repository-verify-exhausted
|
|
1431
1484
|
Status: running
|
|
1432
|
-
Terminal result: null
|
|
1485
|
+
Terminal result: <unchanged from this invocation's start: null, or the preserved historical result>
|
|
1433
1486
|
Lock: released
|
|
1434
1487
|
```
|
|
1435
1488
|
|
|
@@ -1721,8 +1774,12 @@ HEAD, a branch name, or an unpersisted variable.
|
|
|
1721
1774
|
**before** presenting Gate 3: the gate cannot be approved without it.
|
|
1722
1775
|
|
|
1723
1776
|
On NO-GO, classify each finding against the prior round and find its design-level root cause before
|
|
1724
|
-
spending a retry
|
|
1725
|
-
|
|
1777
|
+
spending a retry. A **test-only** finding is fixed in the integration branch under the rules below. A
|
|
1778
|
+
finding in production source has no legal path at this point and must **park top-level needs-human**
|
|
1779
|
+
naming the finding and its root cause: every slice is merged, a merged slice cannot reopen or redispatch,
|
|
1780
|
+
seeding is one-time, and the integration fix is test-only by construction — so "route it to the owning
|
|
1781
|
+
builder in a fresh slice worktree" is an instruction the contract cannot carry out. Parking is the honest
|
|
1782
|
+
outcome and leaves the work recoverable; improvising a reopen is not. A test-only fix there touches test files only — never production
|
|
1726
1783
|
source, never a privileged control-plane path — preserves the property under test or records why it
|
|
1727
1784
|
cannot, lands as its own commit rather than folded into a merge, and is disclosed in the PR body naming
|
|
1728
1785
|
the file and the cause. Respect `max_retries`.
|
|
@@ -1744,12 +1801,7 @@ callouts, remaining risks, and a `## Post-merge test-only repairs` section. When
|
|
|
1744
1801
|
that section states so. Otherwise it summarizes every journal record in order, including introducing
|
|
1745
1802
|
merge, attempt, Starting head, trigger result, sorted test paths, cause, property outcome and every
|
|
1746
1803
|
property loss, repair commit, post-repair result, and final or active status. No attempt, outcome, or
|
|
1747
|
-
property loss may be omitted or collapsed into only the latest result.
|
|
1748
|
-
production count using this exact line template:
|
|
1749
|
-
|
|
1750
|
-
```text
|
|
1751
|
-
Production source: <landed count> / 4500
|
|
1752
|
-
```
|
|
1804
|
+
property loss may be omitted or collapsed into only the latest result.
|
|
1753
1805
|
|
|
1754
1806
|
Present that current artifact and open the gate with:
|
|
1755
1807
|
|
|
@@ -1943,12 +1995,8 @@ redundancy: between the approval and this call the integration head can move, an
|
|
|
1943
1995
|
describes a head nobody validated. If `pr` refuses for that reason, the PR you just opened is ahead of
|
|
1944
1996
|
what was approved — say so at the gate rather than recording it anyway.
|
|
1945
1997
|
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
```text
|
|
1949
|
-
Production source ceiling: <landed count> / 4500
|
|
1950
|
-
```
|
|
1951
|
-
|
|
1998
|
+
This contract sets **no limit on how much code a change may add**. Apply a code ceiling only when the
|
|
1999
|
+
request or target repository explicitly sets one; factory supplies no default ceiling.
|
|
1952
2000
|
When `.factory/$R/artifacts/post-merge-repairs.md` exists, validate it again and include every attempt under
|
|
1953
2001
|
`## Post-merge test-only repairs` in `BODY_FILE`: introducing merge, attempt, Starting head, trigger and
|
|
1954
2002
|
post-repair results, files, cause, property outcome, repair commit, and status. Never omit an earlier
|
|
@@ -2139,12 +2187,9 @@ Never re-do a side effect the manifest shows already done — ticket creation, p
|
|
|
2139
2187
|
ready-for-review is `pr_draft`'s decision, not this rule's.
|
|
2140
2188
|
- **Scope discipline and no fabrication.** Flag out-of-scope work at the next gate. Never invent paths,
|
|
2141
2189
|
keys, versions, or test passes — if the evidence is thin, say so and ask.
|
|
2142
|
-
- **A repository may lock its own scope, and a lock is not a defect.**
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
number and the reason. Editing the limit to make the suite green removes the only thing holding the
|
|
2149
|
-
scope, and the failure message tells you the number, so you never need to be told it in advance.
|
|
2150
|
-
Widening one is the engineer's decision, not yours.
|
|
2190
|
+
- **A repository may lock its own scope, and a lock is not a defect.** Honor explicit limits such as
|
|
2191
|
+
coverage floors, bundle or performance budgets, file-length caps, and dependency allowlists. If work
|
|
2192
|
+
needs a wider limit, surface the value and reason at the gate; widening it is the engineer's decision.
|
|
2193
|
+
Expected-value ledgers and snapshots may instead record the current result, not a scope limit. Read
|
|
2194
|
+
the repository's policy and the assertion's purpose before classifying a failure. Update such records
|
|
2195
|
+
when the intended change requires it; do not change an explicit limit just to make tests green.
|
|
@@ -21,7 +21,7 @@ Implement the backend of a technical brief. Write production code in this reposi
|
|
|
21
21
|
- **You implement ONE slice, not the whole backend.** The orchestrator gives you a single **slice spec** (its `paths`, acceptance criteria, and test plan) in an isolated slice worktree `$WT` branched for that slice. Implement only that slice's acceptance criteria, and edit only files under the slice's `paths` — out-of-lane edits get rejected by the reviewer and risk colliding with a parallel slice on merge.
|
|
22
22
|
- **Stay in your lane:** within your slice's `paths`, and only backend paths — the source, resource and test trees the research map identifies as backend. Never touch frontend paths (frontend-builder owns those) or vendored/subtree directories that are pull-only.
|
|
23
23
|
- Implement **only what the brief specifies.** No drive-by refactors, no speculative abstraction.
|
|
24
|
-
-
|
|
24
|
+
- Follow the target repository's documented code-comment policy (`AGENTS.md`, `CLAUDE.md`, or their linked rules); do not assume a blanket ban on comments.
|
|
25
25
|
|
|
26
26
|
## How to build
|
|
27
27
|
|
|
@@ -76,7 +76,7 @@ Return this as your final message:
|
|
|
76
76
|
**API surface change:** <exact schema or route change> | none
|
|
77
77
|
|
|
78
78
|
**Verification:**
|
|
79
|
-
-
|
|
79
|
+
- build/typecheck: `<actual repository command>` — pass/fail (or "not applicable — reason")
|
|
80
80
|
- tests run: `<names>` — pass/fail (or "none — reason")
|
|
81
81
|
|
|
82
82
|
**Commit:** <sha + subject>
|
|
@@ -22,7 +22,7 @@ Implement the frontend of a technical brief. Write production code in this repos
|
|
|
22
22
|
- **You implement ONE slice, not the whole frontend.** The orchestrator gives you a single **slice spec** (its `paths`, acceptance criteria, and test plan) in an isolated slice worktree `$WT` branched for that slice. Implement only that slice's acceptance criteria, and edit only files under the slice's `paths` — out-of-lane edits get rejected by the reviewer and risk colliding with a parallel slice on merge.
|
|
23
23
|
- **Stay in your lane:** within your slice's `paths`, and only frontend paths. Never touch backend paths — that's the backend-builder.
|
|
24
24
|
- Implement **only what the brief specifies.** No drive-by refactors.
|
|
25
|
-
-
|
|
25
|
+
- Follow the target repository's documented code-comment policy (`AGENTS.md`, `CLAUDE.md`, or their linked rules); do not assume a blanket ban on comments.
|
|
26
26
|
- For framework API questions, use whatever framework skill or documentation tool this repository provides rather than guessing from older patterns.
|
|
27
27
|
|
|
28
28
|
## How to build (repo frontend rules — non-negotiable)
|
|
@@ -27,7 +27,7 @@ The skeptic. The builders and test-verifier just reported success — your job i
|
|
|
27
27
|
|
|
28
28
|
1. **Acceptance criteria coverage:** each AC from the story — is it actually implemented AND tested? Map AC → code → test. Flag any AC with code but no test, or a test that doesn't really assert the criterion.
|
|
29
29
|
2. **Brief adherence:** did the builders follow the layered plan, the named patterns, and the read-path, API-surface and migration decisions? Note deviations and whether they're defensible.
|
|
30
|
-
3. **Repo conventions** (`AGENTS.md` or `CLAUDE.md`, and the rules files they point at): layering, component conventions, migration metadata and registration, no vendored-tree edits,
|
|
30
|
+
3. **Repo conventions** (`AGENTS.md` or `CLAUDE.md`, and the rules files they point at): layering, component conventions, migration metadata and registration, no vendored-tree edits, and the target repository's documented code-comment policy (not an assumed blanket ban).
|
|
31
31
|
4. **Correctness & blast radius:** obvious bugs, missing null/error handling, auth/role gaps, N+1 risks, migration safety in production, feature-flag gating.
|
|
32
32
|
5. **Scope:** anything built that the story didn't ask for (scope creep) or any out-of-scope file touched.
|
|
33
33
|
|
package/agents/story-writer.md
CHANGED
|
@@ -49,8 +49,8 @@ Return this as your final message:
|
|
|
49
49
|
- Out: <...>
|
|
50
50
|
|
|
51
51
|
**Suggested ticket fields (orchestrator will use these if you approve creating the ticket):**
|
|
52
|
-
- Issue type:
|
|
53
|
-
- Components: <
|
|
52
|
+
- Issue type: <choose from the supplied tracker issue types; if unavailable, mark unspecified>
|
|
53
|
+
- Components: <choose from the supplied tracker components; if unavailable, mark unspecified>
|
|
54
54
|
- Labels: <optional>
|
|
55
55
|
|
|
56
56
|
**Should this be split?** <no | yes — propose N stories with one-line titles>
|
|
@@ -59,4 +59,4 @@ Return this as your final message:
|
|
|
59
59
|
- <call out every assumption so the human can correct it at the gate>
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Never fabricate an
|
|
62
|
+
Never fabricate an issue key or claim a ticket exists — you only draft. The orchestrator handles creation.
|
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.
|
|
@@ -82,15 +82,14 @@ a re-read rather than a run.
|
|
|
82
82
|
6. **A slice must be able to make its ratified `test_plan` green using only the paths it owns.** This is
|
|
83
83
|
one invariant with several faces, and it is the only rule in this list whose violation admits *no legal
|
|
84
84
|
move*: `paths` freeze at seeding, a blocked slice's dependents cannot be dispatched, and the slice's own
|
|
85
|
-
ratified command includes whatever its change affected. No retry count fixes it.
|
|
86
|
-
here, each arriving at it differently.
|
|
85
|
+
ratified command includes whatever its change affected. No retry count fixes it.
|
|
87
86
|
|
|
88
87
|
**The check is mechanical, and it is the same one every time.** Cross-check every slice's `test_plan`
|
|
89
88
|
against every other slice's `paths` before you emit the plan. For each slice ask: when this command
|
|
90
89
|
runs, is everything it must change in order to pass owned by *this* slice? If not, the plan is wrong,
|
|
91
90
|
whatever the topic suggests. Ownership follows the change, not the subject matter.
|
|
92
91
|
|
|
93
|
-
Three
|
|
92
|
+
Three forms of this ownership problem:
|
|
94
93
|
|
|
95
94
|
- **Proving an absence a later slice fills.** Two kinds, and only the first is a contradiction:
|
|
96
95
|
- **Invalidated when the later path lands.** The claim holds only while the thing is absent, so the
|
|
@@ -110,15 +109,11 @@ a re-read rather than a run.
|
|
|
110
109
|
once not.
|
|
111
110
|
- **Breaking callers a later slice owns.** If a change invalidates existing call sites, fixtures or
|
|
112
111
|
tests — a signature, a return shape, sync/async nature, a module contract other code imports — those
|
|
113
|
-
belong to the slice making the change
|
|
114
|
-
`evidence-routing` made `observe_evidence` async and SafeGit-only, sixteen orchestrator and reattach
|
|
115
|
-
tests called the old contract, and only the dependent `orchestrator-publication` slice owned them.
|
|
112
|
+
belong to the slice making the change, not a dependent slice.
|
|
116
113
|
- **Moving a repo-wide rule whose inventory another slice owns.** A closed-inventory test — every env
|
|
117
114
|
var documented, every tool in an allowlist, every surface in a list, a budget or a limit — fails the
|
|
118
|
-
moment your change adds a member, and passes again only when the inventory is updated.
|
|
119
|
-
|
|
120
|
-
`tests/test_config_docs_complete.py` sat in a later slice; the merged slice could not be repaired,
|
|
121
|
-
because a merged slice cannot be amended.
|
|
115
|
+
moment your change adds a member, and passes again only when the inventory is updated. The slice
|
|
116
|
+
adding the member must own the required inventory updates; a merged slice cannot be amended.
|
|
122
117
|
|
|
123
118
|
**The trigger is invalidation, not change.** A backward-compatible change needs none of this: a
|
|
124
119
|
defaulted optional parameter, or an added field on a returned object, leaves every existing caller,
|
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
|
+
- Code comments follow the target repository's documented policy; do not invent a blanket ban.
|
|
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
|
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
|
|
|
13
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,11 +26,16 @@ 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"]),
|
|
32
36
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
33
37
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
38
|
+
decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
|
|
34
39
|
// No --force: `lock <id> steal` is the same operation with a name that says what it
|
|
35
40
|
// does, and two spellings of "take someone else's lock" is one too many.
|
|
36
41
|
lock: Object.freeze(["--repo", "--session", "--branch", "--ttl-ms", "--now", "--json"]),
|
|
@@ -645,6 +650,47 @@ const HANDLERS = {
|
|
|
645
650
|
return emit(flags, { run_id: runId, seeded: next.slices.length, slices: next.slices.map((slice) => slice.id) });
|
|
646
651
|
},
|
|
647
652
|
|
|
653
|
+
// Enforcement: publish cumulative decision text separately, then atomically select it in run.json.
|
|
654
|
+
// A refused transition must not change the artifact the driver was told to read. Unreferenced files
|
|
655
|
+
// from an interrupted publication are harmless; only operator_decision.artifact is authoritative.
|
|
656
|
+
async decide([runId], flags) {
|
|
657
|
+
if (!runId) throw new CliError("factory decide requires <run-id>");
|
|
658
|
+
if (typeof flags.text !== "string" || !flags.text.trim()) throw new CliError("factory decide requires nonblank --text <decision>");
|
|
659
|
+
if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory decide requires nonblank --session <id>");
|
|
660
|
+
const runDir = runDirFor(flags, runId);
|
|
661
|
+
const current = readRun(runDir);
|
|
662
|
+
if (current.status !== "needs-human") {
|
|
663
|
+
throw new CliError(`factory decide requires current status needs-human; found '${current.status}'`);
|
|
664
|
+
}
|
|
665
|
+
const owner = assertFreshSessionOwner(runDir, runId, flags.session, "decide");
|
|
666
|
+
const at = stamp(flags);
|
|
667
|
+
if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("decide must move updated_at forwards");
|
|
668
|
+
const prior = current.operator_decision;
|
|
669
|
+
const existing = prior ? readFileSync(join(runDir, prior.artifact), "utf8") : "";
|
|
670
|
+
if (prior && planDigest(Buffer.from(existing)) !== prior.digest) throw new CliError("recorded operator decision does not match its digest");
|
|
671
|
+
const bytes = Buffer.from(`${existing}${existing ? "\n" : ""}## ${at}\n\n${flags.text.trim()}\n`);
|
|
672
|
+
const digest = planDigest(bytes);
|
|
673
|
+
const artifact = join("artifacts", `operator-decisions-${digest.slice(7)}.md`);
|
|
674
|
+
const absolute = join(runDir, artifact);
|
|
675
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
676
|
+
if (existsSync(absolute)) {
|
|
677
|
+
if (!lstatSync(absolute).isFile() || !readFileSync(absolute).equals(bytes)) throw new CliError("operator decision artifact differs from the intended bytes");
|
|
678
|
+
} else {
|
|
679
|
+
await writeProtectedFileAtomic(dirname(absolute), basename(absolute), bytes, { createOnly: true });
|
|
680
|
+
}
|
|
681
|
+
const next = await transition(runDir, {
|
|
682
|
+
participants: [{ familyId: "envelope", mode: "decide" }],
|
|
683
|
+
apply: (state) => {
|
|
684
|
+
if (!isDeepStrictEqual(state, current)) throw new CliError("run changed while recording the operator decision; retry from current state");
|
|
685
|
+
return { ...state, updated_at: at, operator_decision: { at, digest, artifact } };
|
|
686
|
+
},
|
|
687
|
+
finalGuard: () => {
|
|
688
|
+
if (!sameSessionOwner(runDir, owner)) throw new CliError("operator decision session ownership changed; current decision was preserved");
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
return emit(flags, { run_id: runId, status: next.status, operator_decision: next.operator_decision });
|
|
692
|
+
},
|
|
693
|
+
|
|
648
694
|
async ["amend-paths"](positional, flags) {
|
|
649
695
|
if (positional.length !== 2) throw new CliError("factory amend-paths requires exactly <run-id> <slice-id>");
|
|
650
696
|
const [runId, sliceId] = positional;
|
|
@@ -976,7 +1022,8 @@ const HANDLERS = {
|
|
|
976
1022
|
// intact in `run.json` -- and `at` is what tells a controller whether "approved" happened a minute
|
|
977
1023
|
// ago or three hours ago, which is most of what "is this run stuck" means.
|
|
978
1024
|
gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name])
|
|
979
|
-
.map((name) => [name, { status: run.gates[name].status, at: run.gates[name].at ?? null,
|
|
1025
|
+
.map((name) => [name, { status: run.gates[name].status, at: run.gates[name].at ?? null,
|
|
1026
|
+
artifact: run.gates[name].artifact ?? null, reviewed_head: run.gates[name].reviewed_head ?? null }])),
|
|
980
1027
|
// Structured, not `${agent}:${status}(${attempts})`. Attempts are the field a controller reads to
|
|
981
1028
|
// decide whether an attempt was consumed, and reaching them meant regexing a display string out of
|
|
982
1029
|
// a JSON contract. Nothing in the suite asserted the string form, so it was a public shape with no
|
|
@@ -996,6 +1043,8 @@ const HANDLERS = {
|
|
|
996
1043
|
// Both, deliberately. `next_action` is the machine answer; `next` is its rendering, derived from the
|
|
997
1044
|
// same record by one formatter so they cannot drift, and kept because the driver contract, the
|
|
998
1045
|
// sidebar and a lot of prose name `next: gate:story`.
|
|
1046
|
+
// Identifies the committed decision bytes, not whether the driver applied them.
|
|
1047
|
+
operator_decision: run.operator_decision ?? null,
|
|
999
1048
|
next_action: nextActionRecord(run),
|
|
1000
1049
|
next: nextAction(run),
|
|
1001
1050
|
});
|
|
@@ -1071,6 +1120,12 @@ const HANDLERS = {
|
|
|
1071
1120
|
status: decision,
|
|
1072
1121
|
at: decision === "pending" ? null : at,
|
|
1073
1122
|
artifact: flags.artifact ?? state.gates[name]?.artifact ?? null,
|
|
1123
|
+
// What this approval judged. The reobserver above already observes the integration head to
|
|
1124
|
+
// prove readiness; recording it is what lets publication tell a fresh approval from a stale
|
|
1125
|
+
// one, which is the whole difference between re-running tests and re-approving.
|
|
1126
|
+
reviewed_head: name === "pre_pr" && decision === "approved"
|
|
1127
|
+
? integrationHead(repo, state).commit
|
|
1128
|
+
: state.gates[name]?.reviewed_head ?? null,
|
|
1074
1129
|
},
|
|
1075
1130
|
},
|
|
1076
1131
|
...(name === "brief" ? { plan_digest: briefDigestFor(decision, state, runDir) } : {}),
|
|
@@ -1084,6 +1139,38 @@ const HANDLERS = {
|
|
|
1084
1139
|
if (!STEP_STATUSES.includes(status)) throw new CliError(`status must be one of ${STEP_STATUSES.join(" | ")}`);
|
|
1085
1140
|
const runDir = runDirFor(flags, runId);
|
|
1086
1141
|
assertRunNotParked(runDir, "step");
|
|
1142
|
+
// The reference was stored and never read, so `accepted` was recorded against a missing review file,
|
|
1143
|
+
// a REJECT with blocking fixes, and an approval naming a commit that does not exist -- all three
|
|
1144
|
+
// reproduced through this CLI. That made the enforcement the README claims, and the workflow's
|
|
1145
|
+
// "must APPROVE before you accept that step", instruction rather than fact. A reviewed step now
|
|
1146
|
+
// consumes its review the way a slice does, minus the head binding: a planning subject's output is
|
|
1147
|
+
// an artifact in the control plane, not a commit, so there is no head for the review to name.
|
|
1148
|
+
const priorStep = readRunUnchecked(runDir).run?.steps?.find((step) => step.agent === agent) ?? null;
|
|
1149
|
+
const reviewedRef = flags.reviewRef ?? priorStep?.review_ref ?? null;
|
|
1150
|
+
if (status === "accepted" && REVIEWED_STEPS.includes(agent)) {
|
|
1151
|
+
if (!reviewedRef) throw new CliError(`step '${agent}' cannot be accepted without --review-ref; work-reviewer must approve it first`);
|
|
1152
|
+
const review = readReview(runDir, reviewedRef);
|
|
1153
|
+
if (review.subject !== agent) throw new CliError(`review '${reviewedRef}' approved '${review.subject}', not '${agent}'`);
|
|
1154
|
+
if (!isApproving(review.verdict)) throw new CliError(`review '${reviewedRef}' verdict is ${review.verdict}, not an approval`);
|
|
1155
|
+
// The attempt, or the reference fallback silently re-consumes the previous round's approval: accept
|
|
1156
|
+
// attempt 1, record `running --attempts 2`, then accept again with no `--review-ref` and the stored
|
|
1157
|
+
// attempt-1 review approves attempt 2. Reproduced through the CLI. The fallback stays -- re-passing
|
|
1158
|
+
// an unchanged reference is ordinary -- but it must still be an approval OF THIS attempt.
|
|
1159
|
+
const attempt = flags.attempts === undefined ? priorStep?.attempts ?? 1 : integer(flags.attempts, 1, "--attempts");
|
|
1160
|
+
if (review.attempt !== attempt) {
|
|
1161
|
+
throw new CliError(`review '${reviewedRef}' is for attempt ${review.attempt}, step is at attempt ${attempt}`);
|
|
1162
|
+
}
|
|
1163
|
+
// A planning subject has no commit to bind to, which is why this check omits the head that a slice
|
|
1164
|
+
// merge requires. `test-verifier` is not a planning subject: it judges the integrated branch, so its
|
|
1165
|
+
// review names a commit and that commit must be the head now. Without this it inherited the planning
|
|
1166
|
+
// exemption and a review naming a nonexistent commit was accepted. Caught in review.
|
|
1167
|
+
if (agent === "test-verifier") {
|
|
1168
|
+
const head = integrationHead(resolve(flags.repo ?? process.cwd()), readRun(runDir)).commit;
|
|
1169
|
+
if (review.reviewed_commit !== head) {
|
|
1170
|
+
throw new CliError(`review '${reviewedRef}' approved ${String(review.reviewed_commit).slice(0, 12)} but the integration head is ${String(head).slice(0, 12)}`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1087
1174
|
const at = stamp(flags);
|
|
1088
1175
|
const next = await transition(runDir, {
|
|
1089
1176
|
participants: [{ familyId: "steps", mode: "record" }],
|
|
@@ -1178,14 +1265,16 @@ const HANDLERS = {
|
|
|
1178
1265
|
if (!isDeepStrictEqual(state, current)) throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
|
|
1179
1266
|
if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
|
|
1180
1267
|
};
|
|
1268
|
+
// Enforcement: never unpark a driver that would keep reading an obsolete staged contract.
|
|
1269
|
+
if (success) await writeProtectedFileAtomic(runDir, "WORKFLOW.md", readFileSync(new URL("../WORKFLOW.md", import.meta.url)));
|
|
1181
1270
|
const next = await transition(runDir, {
|
|
1182
1271
|
participants: [{ familyId: "envelope", mode: success ? "resume-needs-human" : "record-bootstrap" }],
|
|
1183
|
-
|
|
1272
|
+
reobservers: new Map([["envelope", assertBinding]]), finalGuard: ({ state }) => {
|
|
1184
1273
|
if (!readFileSync(join(runDir, "run.json")).equals(boundRunBytes) || !isDeepStrictEqual(state, current)) {
|
|
1185
1274
|
throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
|
|
1186
1275
|
}
|
|
1187
1276
|
if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
|
|
1188
|
-
}
|
|
1277
|
+
},
|
|
1189
1278
|
apply: (state) => ({ ...state, ...(success ? { status: "running" } : {}), updated_at: at,
|
|
1190
1279
|
...(outcome ? { bootstrap_command: config.bootstrapCommand, bootstrap_exit: outcome.exit } : {}) }),
|
|
1191
1280
|
});
|
|
@@ -1566,6 +1655,7 @@ function usage() {
|
|
|
1566
1655
|
factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
|
|
1567
1656
|
factory status <run-id> [--json]
|
|
1568
1657
|
factory amend-paths <run-id> <slice-id> --add PATH [--add PATH ...] --reason TEXT --session ID [--now ISO]
|
|
1658
|
+
factory decide <run-id> --text TEXT --session ID [--now ISO]
|
|
1569
1659
|
factory resume <run-id> --session ID [--now ISO]
|
|
1570
1660
|
factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
|
|
1571
1661
|
factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
|
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.
|
|
@@ -49,11 +53,34 @@ const envelope = contract({
|
|
|
49
53
|
created_at: state.created_at,
|
|
50
54
|
updated_at: state.updated_at,
|
|
51
55
|
terminal_result: state.terminal_result ?? null,
|
|
56
|
+
operator_decision: state.operator_decision ?? null,
|
|
52
57
|
bootstrap_command: state.bootstrap_command,
|
|
53
58
|
bootstrap_exit: state.bootstrap_exit,
|
|
54
59
|
}),
|
|
55
60
|
validateTransition: ({ mode, before, after, current, candidate }) => {
|
|
56
61
|
if (before.status === "needs-human") {
|
|
62
|
+
// Recording an operator decision is the one write a parked run accepts besides amend-paths, and it
|
|
63
|
+
// touches nothing but the decision itself: the park stands, its reason stands, and resuming stays an
|
|
64
|
+
// explicit separate act. Answering the question is not the same as deciding to continue.
|
|
65
|
+
if (mode === "decide") {
|
|
66
|
+
if (after.status !== "needs-human") throw new Error("decide must preserve parked status");
|
|
67
|
+
if (!isDeepStrictEqual(after.terminal_result, before.terminal_result)) {
|
|
68
|
+
throw new Error("decide must preserve terminal_result");
|
|
69
|
+
}
|
|
70
|
+
if (isDeepStrictEqual(after.operator_decision, before.operator_decision)) {
|
|
71
|
+
throw new Error("decide must record a decision");
|
|
72
|
+
}
|
|
73
|
+
if (Date.parse(after.updated_at) <= Date.parse(before.updated_at)) {
|
|
74
|
+
throw new Error("decide must move updated_at forwards");
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(before).filter((key) => !["updated_at", "operator_decision"].includes(key))) {
|
|
77
|
+
if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`decide cannot change envelope.${key}`);
|
|
78
|
+
}
|
|
79
|
+
for (const key of Object.keys(current).filter((key) => !Object.hasOwn(before, key))) {
|
|
80
|
+
if (!isDeepStrictEqual(current[key], candidate[key])) throw new Error(`decide cannot change run.${key}`);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
57
84
|
if (mode === "amend-paths") {
|
|
58
85
|
if (after.status !== "needs-human") throw new Error("amend-paths must preserve parked status");
|
|
59
86
|
if (!isDeepStrictEqual(after.terminal_result, before.terminal_result)) {
|
|
@@ -88,6 +115,7 @@ const envelope = contract({
|
|
|
88
115
|
return;
|
|
89
116
|
}
|
|
90
117
|
if (mode === "amend-paths") throw new Error(`amend-paths requires current status needs-human; found '${before.status}'`);
|
|
118
|
+
if (mode === "decide") throw new Error(`decide requires current status needs-human; found '${before.status}'`);
|
|
91
119
|
if (["resume-needs-human", "record-bootstrap"].includes(mode)) throw new Error(`${mode} requires current status needs-human; found '${before.status}'`);
|
|
92
120
|
// Identity is immutable for the life of a run. Nothing legitimate renames a
|
|
93
121
|
// run, and allowing it would let a transition retarget another run's record.
|
|
@@ -194,8 +222,30 @@ const steps = contract({
|
|
|
194
222
|
}
|
|
195
223
|
if (step.attempts < prior.attempts) throw new Error(`step '${step.agent}' attempts cannot decrease`);
|
|
196
224
|
if (step.attempts > prior.attempts + 1) throw new Error(`step '${step.agent}' attempts cannot skip`);
|
|
197
|
-
|
|
198
|
-
|
|
225
|
+
// An accepted step was frozen in every direction except accepted -> accepted, so a Gate 2 revision
|
|
226
|
+
// could record its success and never its rejection: the reviewer's REJECT on the revised artifact
|
|
227
|
+
// had no legal transition. Reopening is a REVISION, which is narrower than "any raised attempt" --
|
|
228
|
+
// the first version of this allowed a raised attempt alone, and that reopened settled planning work
|
|
229
|
+
// after the plan was seeded and reopened steps on completed, blocked and partial runs, none of which
|
|
230
|
+
// any lifecycle asks for. Caught in review, reproduced through the CLI.
|
|
231
|
+
// Any departure from the settled row is a revision -- including accepted -> accepted at a higher
|
|
232
|
+
// attempt, which is what a driver recording only the successful final result produces. Scoping this
|
|
233
|
+
// to a status change let that path skip every restriction below, on a terminal run included. Exact
|
|
234
|
+
// same-attempt re-acceptance stays legal, because that is what a resumed driver re-records.
|
|
235
|
+
const revises = prior.status === "accepted" && (step.status !== "accepted" || step.attempts > prior.attempts);
|
|
236
|
+
if (revises) {
|
|
237
|
+
if (step.status !== "accepted" && step.attempts === prior.attempts) {
|
|
238
|
+
throw new Error(`step '${step.agent}' is already accepted; a revision must raise --attempts`);
|
|
239
|
+
}
|
|
240
|
+
if (TERMINAL_STATUSES.includes(candidate.status)) {
|
|
241
|
+
throw new Error(`step '${step.agent}' cannot reopen on a ${candidate.status} run`);
|
|
242
|
+
}
|
|
243
|
+
// A planning revision belongs before the plan is acted on. Once slices are seeded, the decomposition
|
|
244
|
+
// the step produced is what every slice was derived from, so reopening it here changes nothing that
|
|
245
|
+
// has already been built and leaves the run describing a plan it did not follow.
|
|
246
|
+
if (PLANNING_STEPS.includes(step.agent) && (candidate.slices ?? []).length > 0) {
|
|
247
|
+
throw new Error(`step '${step.agent}' cannot reopen after slices are seeded`);
|
|
248
|
+
}
|
|
199
249
|
}
|
|
200
250
|
if (!STEP_STATUSES.includes(step.status)) throw new Error(`step '${step.agent}' status is invalid`);
|
|
201
251
|
}
|
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/schema.js
CHANGED
|
@@ -15,6 +15,13 @@ export const RUN_KEYS = Object.freeze([
|
|
|
15
15
|
"version", "run_id", "issue_key", "branch", "worktree", "pr_base", "pr_draft", "created_at", "updated_at",
|
|
16
16
|
"status", "mode", "max_parallel_slices", "max_retries",
|
|
17
17
|
"gates", "steps", "slices", "validator", "terminal_result", "pr_url",
|
|
18
|
+
// The one channel an operator has into a parked run. `resume` carries no message and every command
|
|
19
|
+
// that could carry a decision is refused while parked, so a park that asks a question -- a changed
|
|
20
|
+
// ceiling, a scope call -- could only be answered by editing the issue, which a retained run never
|
|
21
|
+
// re-reads. The decision is recorded against the run instead, as `{at, digest, artifact}`, and the
|
|
22
|
+
// digest identifies the recorded bytes; application remains the driver's responsibility. Absent in manifests
|
|
23
|
+
// written before this field existed, which still read.
|
|
24
|
+
"operator_decision",
|
|
18
25
|
// Recorded at init from the flag or the environment, never from a checked-in file: the account
|
|
19
26
|
// a run is expected to publish as is a property of where it runs, not of the repository. Absent
|
|
20
27
|
// only in manifests written before 0.8.0, which still read.
|
|
@@ -31,7 +38,12 @@ export const MODES = Object.freeze(["interactive", "headless", "autonomous"]);
|
|
|
31
38
|
|
|
32
39
|
export const GATE_NAMES = Object.freeze(["story", "brief", "pre_pr"]);
|
|
33
40
|
export const GATE_STATUSES = Object.freeze(["pending", "approved", "changes", "stop"]);
|
|
34
|
-
|
|
41
|
+
// `reviewed_head` is the integration head a `pre_pr` approval judged. Without it nothing bound a human
|
|
42
|
+
// approval to a commit, so on a single-slice run -- which skips the validator that carries that binding
|
|
43
|
+
// for multi-slice runs -- approving at A, committing B and re-observing tests at B published under the
|
|
44
|
+
// older approval. Fresh evidence is not fresh approval. Optional, because every other gate has no head
|
|
45
|
+
// to name and a manifest written before this field exists stays valid.
|
|
46
|
+
export const GATE_KEYS = Object.freeze(["status", "at", "artifact", "reviewed_head"]);
|
|
35
47
|
|
|
36
48
|
export const STEP_STATUSES = Object.freeze(["running", "accepted", "rejected", "blocked"]);
|
|
37
49
|
export const STEP_KEYS = Object.freeze(["agent", "status", "attempts", "review_ref", "evidence_ref"]);
|
|
@@ -113,6 +125,14 @@ export function validateRun(run) {
|
|
|
113
125
|
for (const key of ["branch", "worktree"]) required(errors, run, key, "run");
|
|
114
126
|
for (const key of ["created_at", "updated_at"]) pattern(errors, run, key, ISO, "run");
|
|
115
127
|
for (const key of ["max_parallel_slices", "max_retries"]) positiveInt(errors, run, key, "run");
|
|
128
|
+
if (run.operator_decision !== null && run.operator_decision !== undefined) {
|
|
129
|
+
const path = "run.operator_decision";
|
|
130
|
+
if (object(errors, run.operator_decision, path, ["at", "digest", "artifact"])) {
|
|
131
|
+
pattern(errors, run.operator_decision, "at", ISO, path);
|
|
132
|
+
pattern(errors, run.operator_decision, "digest", /^sha256:[0-9a-f]{64}$/u, path);
|
|
133
|
+
runLocalRef(errors, run.operator_decision, "artifact", path);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
116
136
|
for (const key of ["issue_key", "pr_base", "pr_url", "plan_digest", "publishing_identity"]) optionalString(errors, run, key, "run");
|
|
117
137
|
if (Object.hasOwn(run, "pr_draft") && typeof run.pr_draft !== "boolean") {
|
|
118
138
|
errors.push({ path: "run.pr_draft", message: "must be a boolean" });
|
|
@@ -143,6 +163,7 @@ function gates(errors, value) {
|
|
|
143
163
|
if (gate === undefined) continue;
|
|
144
164
|
const path = `run.gates.${name}`;
|
|
145
165
|
if (!object(errors, gate, path, GATE_KEYS)) continue;
|
|
166
|
+
if (gate.reviewed_head !== null && gate.reviewed_head !== undefined) optionalPattern(errors, gate, "reviewed_head", SHA, path);
|
|
146
167
|
enumValue(errors, gate, "status", GATE_STATUSES, path);
|
|
147
168
|
if (gate.at !== null) optionalPattern(errors, gate, "at", ISO, path);
|
|
148
169
|
if (gate.artifact !== undefined && gate.artifact !== null) optionalString(errors, gate, "artifact", path);
|