feature-factory 0.8.9 → 0.9.1
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/WORKFLOW.md +55 -29
- package/agents/backend-builder.md +12 -2
- package/agents/frontend-builder.md +11 -1
- package/agents/implementation-validator.md +1 -1
- package/agents/story-writer.md +3 -3
- package/agents/work-decomposer.md +5 -10
- package/agents/work-reviewer.md +11 -1
- package/bin/factory.js +49 -2
- package/core/contracts.js +24 -0
- package/package.json +1 -1
- package/state/schema.js +15 -0
package/WORKFLOW.md
CHANGED
|
@@ -191,8 +191,28 @@ left by an earlier park: neither is evidence for this park. Publishing again is
|
|
|
191
191
|
|
|
192
192
|
For top-level needs-human, status exposes the durable next action, but no command may execute it before explicit factory resume.
|
|
193
193
|
Retain the sandbox for top-level needs-human while parked, then explicitly resume it after the external fix.
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
216
|
`status.next`; it does not re-resolve the issue, re-read `ISSUE_PAYLOAD`, or regenerate the story or
|
|
197
217
|
brief, so an edited issue body cannot reach the artifacts a retained run will keep using. The supported
|
|
198
218
|
route is: record the decision in the issue body, then have the operator remove the retained sandbox
|
|
@@ -203,8 +223,9 @@ run, and `factory init` refuses while either manifest candidate exists, so a rel
|
|
|
203
223
|
reselects the parked run instead of replacing it. OPERATING.md carries the command and its cost --
|
|
204
224
|
everything held only in that sandbox is lost, including merged slices whose branches were never pushed,
|
|
205
225
|
so push anything worth keeping first.
|
|
206
|
-
Resume is for external causes -- a timeout, an outage, credentials, an unclean tree --
|
|
207
|
-
|
|
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.
|
|
208
229
|
State that route in the park reason, because a decision recorded only in a host session or a sandbox
|
|
209
230
|
artifact is lost with that sandbox, and the replacement run asks the same question again.
|
|
210
231
|
|
|
@@ -486,8 +507,8 @@ tracker URL to select a run declares a `resolve` command recognizing those forms
|
|
|
486
507
|
payload above. Recognition belongs to the declaration for the same reason fetching does: deciding that a
|
|
487
508
|
bare integer is a reference, rather than a feature description, is repository-specific.
|
|
488
509
|
|
|
489
|
-
|
|
490
|
-
|
|
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.
|
|
491
512
|
|
|
492
513
|
#### Resolver and repository verification boundaries
|
|
493
514
|
|
|
@@ -592,7 +613,7 @@ Resume order 3 — complete the existing effective-push proof.
|
|
|
592
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.
|
|
593
614
|
Resume order 5 — immediately before claiming, rerun the final operator exact-ref-absent guard.
|
|
594
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.
|
|
595
|
-
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.
|
|
596
617
|
Resume order 8 — run only existing post-lock reconciliation for an already-recorded merge, its evidence, and repository verification.
|
|
597
618
|
Resume order 9 — continue solely from the newly qualified status.next.
|
|
598
619
|
|
|
@@ -600,6 +621,14 @@ For configured order 7, the CLI binds the exact raw `run.json` bytes, the valida
|
|
|
600
621
|
|
|
601
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.
|
|
602
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
|
+
|
|
603
632
|
When the parked cause is an insufficient ownership declaration for an existing unmerged slice, the
|
|
604
633
|
operator may insert exactly one optional action after order 6 has verified the fresh exact owner and
|
|
605
634
|
unchanged parked result, and before the unchanged explicit resume in order 7:
|
|
@@ -625,10 +654,19 @@ When the run reports a nonempty `publishing_identity`, the mandatory guard below
|
|
|
625
654
|
boundary between completion of resume order 7 and the first operation in resume order 8. Nothing may
|
|
626
655
|
intervene between the verified running/same-owner result and that guard, or between a successful guard
|
|
627
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.
|
|
628
658
|
|
|
629
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.
|
|
630
660
|
|
|
631
|
-
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.
|
|
632
670
|
|
|
633
671
|
Before requesting a fresh run, inspect only the two deterministic manifest candidates described by the
|
|
634
672
|
CLI contract: the legacy candidate under `O/.factory/R` and the sandbox candidate under
|
|
@@ -1763,12 +1801,7 @@ callouts, remaining risks, and a `## Post-merge test-only repairs` section. When
|
|
|
1763
1801
|
that section states so. Otherwise it summarizes every journal record in order, including introducing
|
|
1764
1802
|
merge, attempt, Starting head, trigger result, sorted test paths, cause, property outcome and every
|
|
1765
1803
|
property loss, repair commit, post-repair result, and final or active status. No attempt, outcome, or
|
|
1766
|
-
property loss may be omitted or collapsed into only the latest result.
|
|
1767
|
-
production count using this exact line template:
|
|
1768
|
-
|
|
1769
|
-
```text
|
|
1770
|
-
Production source: <landed count> / 4500
|
|
1771
|
-
```
|
|
1804
|
+
property loss may be omitted or collapsed into only the latest result.
|
|
1772
1805
|
|
|
1773
1806
|
Present that current artifact and open the gate with:
|
|
1774
1807
|
|
|
@@ -1962,12 +1995,8 @@ redundancy: between the approval and this call the integration head can move, an
|
|
|
1962
1995
|
describes a head nobody validated. If `pr` refuses for that reason, the PR you just opened is ahead of
|
|
1963
1996
|
what was approved — say so at the gate rather than recording it anyway.
|
|
1964
1997
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
```text
|
|
1968
|
-
Production source ceiling: <landed count> / 4500
|
|
1969
|
-
```
|
|
1970
|
-
|
|
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.
|
|
1971
2000
|
When `.factory/$R/artifacts/post-merge-repairs.md` exists, validate it again and include every attempt under
|
|
1972
2001
|
`## Post-merge test-only repairs` in `BODY_FILE`: introducing merge, attempt, Starting head, trigger and
|
|
1973
2002
|
post-repair results, files, cause, property outcome, repair commit, and status. Never omit an earlier
|
|
@@ -2158,12 +2187,9 @@ Never re-do a side effect the manifest shows already done — ticket creation, p
|
|
|
2158
2187
|
ready-for-review is `pr_draft`'s decision, not this rule's.
|
|
2159
2188
|
- **Scope discipline and no fabrication.** Flag out-of-scope work at the next gate. Never invent paths,
|
|
2160
2189
|
keys, versions, or test passes — if the evidence is thin, say so and ask.
|
|
2161
|
-
- **A repository may lock its own scope, and a lock is not a defect.**
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
number and the reason. Editing the limit to make the suite green removes the only thing holding the
|
|
2168
|
-
scope, and the failure message tells you the number, so you never need to be told it in advance.
|
|
2169
|
-
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
|
|
|
@@ -45,6 +45,16 @@ introducing a shape the repo does not use.
|
|
|
45
45
|
|
|
46
46
|
## Verify before reporting
|
|
47
47
|
|
|
48
|
+
Before expanding a class-wide behavioral test matrix, run one representative negative control
|
|
49
|
+
within your slice's owned paths. Change production behavior without changing the tests or preventing
|
|
50
|
+
execution. Confirm the mapped test passes before the mutation, fails on the expected behavioral
|
|
51
|
+
assertion with the mutation, and passes after restoration. A syntax, import, discovery, or unrelated
|
|
52
|
+
failure does not count. Restore the mutation before committing or reporting.
|
|
53
|
+
In your narrative report, name the inventory row, production symbol and mutation, exact test command,
|
|
54
|
+
observed assertion failure, and restoration result. Mark an unperformed control as **not run** with
|
|
55
|
+
the reason; never infer a result. This is diagnostic instruction, not a new claim-schema field or a
|
|
56
|
+
replacement for the ratified test run.
|
|
57
|
+
|
|
48
58
|
From the worktree, compile and run the narrowest relevant tests:
|
|
49
59
|
Use the repo's own build and test commands, scoped as narrowly as they allow — a compile or
|
|
50
60
|
type-check step, then the specific test class or file you touched, not the full suite. If the
|
|
@@ -76,7 +86,7 @@ Return this as your final message:
|
|
|
76
86
|
**API surface change:** <exact schema or route change> | none
|
|
77
87
|
|
|
78
88
|
**Verification:**
|
|
79
|
-
-
|
|
89
|
+
- build/typecheck: `<actual repository command>` — pass/fail (or "not applicable — reason")
|
|
80
90
|
- tests run: `<names>` — pass/fail (or "none — reason")
|
|
81
91
|
|
|
82
92
|
**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)
|
|
@@ -48,6 +48,16 @@ and the repo's agent instructions (`AGENTS.md` or `CLAUDE.md`) name the concrete
|
|
|
48
48
|
|
|
49
49
|
## Verify before reporting
|
|
50
50
|
|
|
51
|
+
Before expanding a class-wide behavioral test matrix, run one representative negative control
|
|
52
|
+
within your slice's owned paths. Change production behavior without changing the tests or preventing
|
|
53
|
+
execution. Confirm the mapped test passes before the mutation, fails on the expected behavioral
|
|
54
|
+
assertion with the mutation, and passes after restoration. A syntax, import, discovery, or unrelated
|
|
55
|
+
failure does not count. Restore the mutation before committing or reporting.
|
|
56
|
+
In your narrative report, name the inventory row, production symbol and mutation, exact test command,
|
|
57
|
+
observed assertion failure, and restoration result. Mark an unperformed control as **not run** with
|
|
58
|
+
the reason; never infer a result. This is diagnostic instruction, not a new claim-schema field or a
|
|
59
|
+
replacement for the ratified test run.
|
|
60
|
+
|
|
51
61
|
A fresh worktree may share the main repo's installed dependencies via a link the orchestrator created. If they are missing, run the repo's install command via `bash -c "cd $WT && <install"` once before building.
|
|
52
62
|
|
|
53
63
|
Use the repo's own build or type-check command, run inside `$WT`.
|
|
@@ -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.
|
|
@@ -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
|
@@ -62,6 +62,16 @@ producer for anything it could supply.
|
|
|
62
62
|
|
|
63
63
|
When the subject is a **class-wide** requirement — one that **cannot be established by a bounded witness**, so proving it means checking every in-scope member and the set has to be enumerated first. `all`/`every`/`centralize`/`across` and a whole vulnerability or behavior class are the obvious markers, but the words are instances and not the test: an absence ("no module constructs the runtime"), a preserved property ("behaviour remains unchanged") and a global capability ("the installed artifact works") each require checking every member without using any of them, and the rules below are what make such a claim reviewable at all. An existential claim is the opposite and must **not** be treated as class-wide: "a module constructs the runtime" or "the daemon accepts a connection" is settled by one witness, so demanding an exhaustive inventory for it is over-rejection:
|
|
64
64
|
- The spec must carry a finite source→sink inventory with a per-call-site policy, explicit compatibility/exclusion decisions, and mapped tests. A class-wide spec lacking any of these is a **BLOCKER** — reject it as missing targeted research rather than letting an open-ended "apply everywhere" reach builders.
|
|
65
|
+
For a class-wide behavioral criterion, each inventory row must map to an executable test that
|
|
66
|
+
invokes the relevant production boundary and asserts its required effect or exclusion. The spec
|
|
67
|
+
must name that test approach; build review checks its implementation. Shared parameterized tests
|
|
68
|
+
are valid; separate tests per member are not required.
|
|
69
|
+
AST references and test names prove enumeration, not behavior.
|
|
70
|
+
- Treat the builder's negative-control report as self-reported diagnostic information,
|
|
71
|
+
not independent evidence or proof of coverage for every inventory row. Check whether the named
|
|
72
|
+
mutation tests behavior and the reported failure is the expected behavioral assertion, not a
|
|
73
|
+
syntax, import, discovery, or unrelated failure. Missing attestation alone is not a blocker;
|
|
74
|
+
inadequate behavioral coverage is. Do not approve on the attestation alone.
|
|
65
75
|
- On the first review of a class-wide **spec**, enumerate in one pass **every dimension of under-specification** — not just each same-class instance and call site, but every unresolved contract, policy, migration/grant, auth-gating, state, and test seam — and consolidate them all into one `required_fixes` list. A category surfaced in a later round that was discoverable in the first review is a first-pass miss to record once in `required_fixes` and carry forward until observed fixed — it stays blocking (see the precedence rule below), but do not spawn a duplicate finding or a fresh rejection cycle for it.
|
|
66
76
|
- When you reject a class-wide build, make `required_fixes` **exhaustive for the class** as of the current evidence: consolidate every discoverable in-scope same-class instance and affected call site into this review. Do not cite one example while withholding equivalent findings for a later round — drip-feeding one sink per round (each fix triggering the next rejection) is exactly the churn this rule prevents.
|
|
67
77
|
- **Acceptance bar (do not over-reject):** approve a class-wide spec once its inventory is finite, every in-scope sink carries a decided policy, and every row maps to a test — even if some contracts could be specified in more depth. A deferral or exclusion is legitimate **only when the approved story or scope authorizes it**; never waive, defer, or leave undecided an in-scope sink that falls under an `all`/`every`/`across` criterion. A **bounded residual** may be left to build-time remediation only when it is mechanical implementation detail whose behavior, compatibility, security, auth-gating, migration, and state policy are already decided in the brief — an unresolved behavioral or design decision is not a residual and must be decided before approval, never shipped to builders as an open choice. Reject only for a genuinely missing sink, policy, compatibility decision, or test, not for achievable-but-absent depth.
|
|
@@ -98,7 +108,7 @@ When the subject is a **class-wide** requirement — one that **cannot be establ
|
|
|
98
108
|
- No **hand**-edits to vendored or generated trees. Regeneration is different and is required: the
|
|
99
109
|
slice that changes the source owns regenerating what derives from it, so a regenerated client or
|
|
100
110
|
type set in that slice's lane is expected output, not a finding.
|
|
101
|
-
-
|
|
111
|
+
- Code comments follow the target repository's documented policy; do not invent a blanket ban.
|
|
102
112
|
- **Slice discipline:** the diff stays within the slice's `paths` (out-of-lane edits are a finding).
|
|
103
113
|
- The slice's `acceptance` is actually implemented. This is never waived.
|
|
104
114
|
- The observed tests cover that acceptance — **unless the slice's ratified `test_plan` is empty**,
|
package/bin/factory.js
CHANGED
|
@@ -35,6 +35,7 @@ export const COMMANDS = Object.freeze({
|
|
|
35
35
|
status: Object.freeze(["--repo", "--json"]),
|
|
36
36
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
37
37
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
38
|
+
decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
|
|
38
39
|
// No --force: `lock <id> steal` is the same operation with a name that says what it
|
|
39
40
|
// does, and two spellings of "take someone else's lock" is one too many.
|
|
40
41
|
lock: Object.freeze(["--repo", "--session", "--branch", "--ttl-ms", "--now", "--json"]),
|
|
@@ -649,6 +650,47 @@ const HANDLERS = {
|
|
|
649
650
|
return emit(flags, { run_id: runId, seeded: next.slices.length, slices: next.slices.map((slice) => slice.id) });
|
|
650
651
|
},
|
|
651
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
|
+
|
|
652
694
|
async ["amend-paths"](positional, flags) {
|
|
653
695
|
if (positional.length !== 2) throw new CliError("factory amend-paths requires exactly <run-id> <slice-id>");
|
|
654
696
|
const [runId, sliceId] = positional;
|
|
@@ -1001,6 +1043,8 @@ const HANDLERS = {
|
|
|
1001
1043
|
// Both, deliberately. `next_action` is the machine answer; `next` is its rendering, derived from the
|
|
1002
1044
|
// same record by one formatter so they cannot drift, and kept because the driver contract, the
|
|
1003
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,
|
|
1004
1048
|
next_action: nextActionRecord(run),
|
|
1005
1049
|
next: nextAction(run),
|
|
1006
1050
|
});
|
|
@@ -1221,14 +1265,16 @@ const HANDLERS = {
|
|
|
1221
1265
|
if (!isDeepStrictEqual(state, current)) throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
|
|
1222
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");
|
|
1223
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)));
|
|
1224
1270
|
const next = await transition(runDir, {
|
|
1225
1271
|
participants: [{ familyId: "envelope", mode: success ? "resume-needs-human" : "record-bootstrap" }],
|
|
1226
|
-
|
|
1272
|
+
reobservers: new Map([["envelope", assertBinding]]), finalGuard: ({ state }) => {
|
|
1227
1273
|
if (!readFileSync(join(runDir, "run.json")).equals(boundRunBytes) || !isDeepStrictEqual(state, current)) {
|
|
1228
1274
|
throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
|
|
1229
1275
|
}
|
|
1230
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");
|
|
1231
|
-
}
|
|
1277
|
+
},
|
|
1232
1278
|
apply: (state) => ({ ...state, ...(success ? { status: "running" } : {}), updated_at: at,
|
|
1233
1279
|
...(outcome ? { bootstrap_command: config.bootstrapCommand, bootstrap_exit: outcome.exit } : {}) }),
|
|
1234
1280
|
});
|
|
@@ -1609,6 +1655,7 @@ function usage() {
|
|
|
1609
1655
|
factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
|
|
1610
1656
|
factory status <run-id> [--json]
|
|
1611
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]
|
|
1612
1659
|
factory resume <run-id> --session ID [--now ISO]
|
|
1613
1660
|
factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
|
|
1614
1661
|
factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
|
package/core/contracts.js
CHANGED
|
@@ -53,11 +53,34 @@ const envelope = contract({
|
|
|
53
53
|
created_at: state.created_at,
|
|
54
54
|
updated_at: state.updated_at,
|
|
55
55
|
terminal_result: state.terminal_result ?? null,
|
|
56
|
+
operator_decision: state.operator_decision ?? null,
|
|
56
57
|
bootstrap_command: state.bootstrap_command,
|
|
57
58
|
bootstrap_exit: state.bootstrap_exit,
|
|
58
59
|
}),
|
|
59
60
|
validateTransition: ({ mode, before, after, current, candidate }) => {
|
|
60
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
|
+
}
|
|
61
84
|
if (mode === "amend-paths") {
|
|
62
85
|
if (after.status !== "needs-human") throw new Error("amend-paths must preserve parked status");
|
|
63
86
|
if (!isDeepStrictEqual(after.terminal_result, before.terminal_result)) {
|
|
@@ -92,6 +115,7 @@ const envelope = contract({
|
|
|
92
115
|
return;
|
|
93
116
|
}
|
|
94
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}'`);
|
|
95
119
|
if (["resume-needs-human", "record-bootstrap"].includes(mode)) throw new Error(`${mode} requires current status needs-human; found '${before.status}'`);
|
|
96
120
|
// Identity is immutable for the life of a run. Nothing legitimate renames a
|
|
97
121
|
// run, and allowing it would let a transition retarget another run's record.
|
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.
|
|
@@ -118,6 +125,14 @@ export function validateRun(run) {
|
|
|
118
125
|
for (const key of ["branch", "worktree"]) required(errors, run, key, "run");
|
|
119
126
|
for (const key of ["created_at", "updated_at"]) pattern(errors, run, key, ISO, "run");
|
|
120
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
|
+
}
|
|
121
136
|
for (const key of ["issue_key", "pr_base", "pr_url", "plan_digest", "publishing_identity"]) optionalString(errors, run, key, "run");
|
|
122
137
|
if (Object.hasOwn(run, "pr_draft") && typeof run.pr_draft !== "boolean") {
|
|
123
138
|
errors.push({ path: "run.pr_draft", message: "must be a boolean" });
|