feature-factory 0.9.1 → 0.10.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 +57 -14
- package/WORKFLOW.md +230 -28
- package/agents/story-writer.md +8 -0
- package/agents/work-reviewer.md +6 -1
- package/bin/factory.js +8 -0
- package/bin/restore.js +301 -0
- package/core/atomic-write.js +66 -54
- package/core/effective-push.js +5 -5
- package/observe/repository-config.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,16 @@ host binding and copies this workflow beside that skill at build/pack time. Inst
|
|
|
20
20
|
`opencode-feature-factory` or `prime-agent-feature-factory` for a supported host, or build an adapter
|
|
21
21
|
that loads the complete workflow and drives all durable state changes through the CLI.
|
|
22
22
|
|
|
23
|
+
## Infrastructure failures and attempts
|
|
24
|
+
|
|
25
|
+
A confirmed host-origin availability or transport failure does not spend a specialist attempt. The active
|
|
26
|
+
driver permits one same-attempt recovery for the canonical role and subject only when the host proves
|
|
27
|
+
execution never started or can recover the same child/session. Unbudgeted research and design create no
|
|
28
|
+
attempt. A second consecutive failure parks the run. The count is memory-only and resets with the driver,
|
|
29
|
+
but resume and reset never prove that prior work did not start: the next driver must recover or prove the
|
|
30
|
+
same prior invocation safe before dispatch. Child text, unknown errors, and excluded auth, quota,
|
|
31
|
+
rate-limit, and configuration failures never trigger a free retry or duplicate possibly-started work.
|
|
32
|
+
|
|
23
33
|
## Repository command configuration
|
|
24
34
|
|
|
25
35
|
A repository operator may provide optional `$O/.factory.json`, where `O` is the physically
|
|
@@ -37,9 +47,9 @@ resolved Git top level:
|
|
|
37
47
|
}
|
|
38
48
|
```
|
|
39
49
|
|
|
40
|
-
The root has
|
|
41
|
-
`bootstrap`, and `bootstrap_timeout_ms`.
|
|
42
|
-
strings. There is no `publishing_identity` key, and a file carrying one is malformed because the
|
|
50
|
+
The root has two required properties, `resolve` and `verify`, and five optional properties: `publish`,
|
|
51
|
+
`pr_draft`, `verify_timeout_ms`, `bootstrap`, and `bootstrap_timeout_ms`. Required commands and any present
|
|
52
|
+
`publish` or `bootstrap` are non-empty strings. There is no `publishing_identity` key, and a file carrying one is malformed because the
|
|
43
53
|
optional set is closed. A present `pr_draft` must be a JSON boolean and omission means `true`. Both timeouts are
|
|
44
54
|
positive safe integers. `bootstrap_timeout_ms` requires `bootstrap`.
|
|
45
55
|
Each omitted timeout independently defaults to `900000`; neither shares the other's budget. The file is
|
|
@@ -56,7 +66,7 @@ value stops the run instead of publishing under whatever credential the host hap
|
|
|
56
66
|
it from `gh`, the token, stored authentication, or Git configuration: an expectation read from the
|
|
57
67
|
credential being checked would always match.
|
|
58
68
|
|
|
59
|
-
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`;
|
|
69
|
+
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `publish`.
|
|
60
70
|
|
|
61
71
|
The named forms are `.factory.json entry 'pr_draft' must be a boolean`, `.factory.json entry 'bootstrap' must be a non-empty string`, `.factory.json entry 'bootstrap_timeout_ms' requires a declared bootstrap command`, `.factory.json entry 'bootstrap_timeout_ms' must be a positive integer`, and `.factory.json entry 'verify_timeout_ms' must be a positive integer`.
|
|
62
72
|
|
|
@@ -113,10 +123,14 @@ removed the sandbox destroyed the manifest and every accepted gate with it. `.pa
|
|
|
113
123
|
id, so a snapshot never occupies the completed archive at `$O/.factory/<R>` and never blocks
|
|
114
124
|
re-initialising the same run id. It is published by a staged, verified swap, so a failed later park cannot degrade the last good
|
|
115
125
|
snapshot. A failed snapshot is reported and never prevents the park. `blocked`
|
|
116
|
-
and `partial` are not snapshotted
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
and `partial` are not snapshotted. A retained sandbox resumes directly; when that sandbox is lost,
|
|
127
|
+
`factory restore <R> --repo <O> --from refs/remotes/<remote>/<feature-branch>` rebuilds a parked,
|
|
128
|
+
lockless sandbox from the snapshot and the exact pushed feature ref. It reports every active slice reset
|
|
129
|
+
to `pending` rather than claiming branch-local work survived.
|
|
130
|
+
|
|
131
|
+
Qualified status reports `park_snapshot` for an unchanged live park: the published path, or `null` when no
|
|
132
|
+
matching snapshot exists. A restored generation deliberately reports `null` because its manifest and path
|
|
133
|
+
bindings changed; `status.restore` carries its source digest, feature commit, resets, and invalidations.
|
|
120
134
|
Malformed config, malformed payload, a non-zero exit, or unavailable exit status refuses before any
|
|
121
135
|
run effect and never falls back:
|
|
122
136
|
|
|
@@ -144,7 +158,7 @@ A failed, timed-out, dirty, or unobservable fresh init emits no JSON stdout, ret
|
|
|
144
158
|
|
|
145
159
|
When both bootstrap keys are absent, init and resume are exact no-ops for bootstrap: no execution, manifest fields, output, or response-shape change.
|
|
146
160
|
|
|
147
|
-
Bootstrap never runs during resolver intake, merge verification or replay, direct repository verification, slice observation, Gate 3, effective push, configured publication, push, or PR creation.
|
|
161
|
+
Bootstrap never runs during resolver intake, merge verification or replay, direct repository verification, slice observation, Gate 3, effective push, configured publication, push, or PR creation. Bootstrap does not mediate configured publication, effective-push, push, PR, or Gate 3 behavior.
|
|
148
162
|
|
|
149
163
|
After a slice merge is successfully and atomically recorded, `verify` starts in the exact recorded
|
|
150
164
|
integration worktree. Its configured string is submitted unchanged as one ordinary shell command with
|
|
@@ -190,6 +204,30 @@ Gate 3 always runs a separate fresh integrated `test-verifier` observation at th
|
|
|
190
204
|
overwrites canonical evidence through the existing command mode and never shares, substitutes, or
|
|
191
205
|
optimizes from post-merge evidence, even when the head is unchanged.
|
|
192
206
|
|
|
207
|
+
## Restoring a lost parked sandbox
|
|
208
|
+
|
|
209
|
+
Run restore from the canonical operator repository, not from a replacement checkout:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
factory restore <run-id> --repo <operator-repository> \
|
|
213
|
+
--from refs/remotes/<remote>/<recorded-feature-branch> --json
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The source must be the canonical `$O/.factory/.parked/<run-id>` snapshot. The feature ref must be a full
|
|
217
|
+
remote-tracking ref whose branch suffix exactly matches the manifest and whose commit is still advertised
|
|
218
|
+
by that remote's push endpoint, exactly equal to the operator effective push endpoint. Restore validates
|
|
219
|
+
that snapshot, refuses symlinks that escape its control plane, clones the exact derived sandbox, aligns and
|
|
220
|
+
rechecks the operator's effective push target, and proves every preserved merged-slice Git and evidence
|
|
221
|
+
binding against the pushed head. It omits the old root session lock and publishes the transformed manifest
|
|
222
|
+
last. The run stays parked and no repository-configured bootstrap runs. Nonmerged slices with physical
|
|
223
|
+
branch or worktree state reset to `pending`, and the command reports them. Stale HEAD-bound validator
|
|
224
|
+
approval is invalidated. Gate 3 and test-verifier approval are always invalidated because canonical
|
|
225
|
+
publication evidence names the prior physical generation; canonical verifier records are omitted from the
|
|
226
|
+
copy. The staged slice projection must still match the Brief-bound plan digest and recorded amendments.
|
|
227
|
+
The durable `status.restore` record identifies the source inventory and restored commit. Because the new
|
|
228
|
+
generation has different paths and provenance, `status.park_snapshot` is `null` after a successful restore.
|
|
229
|
+
Claim the new lock and use the ordinary resume sequence only after reviewing those losses.
|
|
230
|
+
|
|
193
231
|
## Resuming a parked run
|
|
194
232
|
|
|
195
233
|
A top-level `needs-human` status is a parked stop, not a final result. `completed`, `partial`, and
|
|
@@ -220,7 +258,7 @@ own the same path. Duplicate, target-already-owned, malformed, privileged, repla
|
|
|
220
258
|
requests refuse atomically. Resume never amends or reseeds. A merge continues to refuse every unamended
|
|
221
259
|
or privileged changed path.
|
|
222
260
|
|
|
223
|
-
`resolve` and `verify` are consumed now, and the run's recorded `publishing_identity` is compared at the publication guards.
|
|
261
|
+
`resolve` and `verify` are consumed now, and the run's recorded `publishing_identity` is compared at the publication guards. Step 6 resolves one selection: a nonblank inherited `FACTORY_PUBLISHING_COMMAND` selects its exact string; that variable set blank or whitespace selects the default; when it is unset, configured `publish` wins if present; otherwise the default wins. Only a selected nondefault command replaces `gh pr create`, after the factory-owned exact push and post-push identity guard. It receives exact `PR_BASE`, `FEATURE_BRANCH`, `PR_DRAFT`, `PR_TITLE`, and absolute `PR_BODY_FILE` environment values. Only exit zero with an absolute HTTPS URL on the last nonempty stdout line is recordable; every other result parks with exact reason `selected publishing command outcome indeterminate; re-observe whether the pull request exists before retry` and no fallback.
|
|
224
262
|
Effective push-target capture and comparison are active through the package-owned `factory effective-push` command; they are not deferred to configured `publish`.
|
|
225
263
|
The recorded `publishing_identity` is read from `status` exactly as reported, without trimming,
|
|
226
264
|
normalization, case-folding, or reserialization. `init` refuses when neither the flag nor the environment
|
|
@@ -230,8 +268,8 @@ can report `null`. `publishing_identity` is a recorded run field reported by `st
|
|
|
230
268
|
With a recorded identity, every mode checks it at exactly three boundaries: immediately
|
|
231
269
|
after verified post-lock ownership, or immediately after an explicit resume is verified running with
|
|
232
270
|
the same fresh owner and before reconciliation or other work; immediately before `git push`, after
|
|
233
|
-
effective push-target equality; and immediately before
|
|
234
|
-
successful. No operation intervenes across a guard boundary. Only a manifest written before 0.8.0, which can
|
|
271
|
+
effective push-target equality; and immediately before the selected PR-creation command, after the
|
|
272
|
+
factory-owned exact push is known successful. No operation intervenes across a guard boundary. Only a manifest written before 0.8.0, which can
|
|
235
273
|
report `null`, skips all three guards; an absent config does not affect them.
|
|
236
274
|
|
|
237
275
|
Before each guard, inherited `GH_TOKEN` must exist and contain at least one character. Missing or empty
|
|
@@ -268,7 +306,7 @@ session.
|
|
|
268
306
|
|
|
269
307
|
Publishing-identity verification is enforcement because it prevents false-green or wrong-account
|
|
270
308
|
publication. Credential provisioning and helper setup are instruction only. Existing push,
|
|
271
|
-
`
|
|
309
|
+
`factory pr`, Gate 3, merge, and approval semantics remain unchanged. The live config
|
|
272
310
|
is not part of this package and no generated config or resolver asset is shipped. See the repository's
|
|
273
311
|
[operator guide](https://github.com/jasoncarreira/feature-factory/blob/main/OPERATING.md) for the
|
|
274
312
|
shared inherited-token helper recipe; it does not acquire, store, install, or repair credentials.
|
|
@@ -285,7 +323,12 @@ repositories and compares them exactly. `check` freshly captures both targets an
|
|
|
285
323
|
configuration. Both modes use shell-free Git subprocesses, write no output on success, and retain the
|
|
286
324
|
sandbox on a fixed redacted failure. Captured targets and child diagnostics are never returned, logged,
|
|
287
325
|
persisted in factory state, printed, or attached as an error cause. The command is independent of
|
|
288
|
-
`publishing_identity
|
|
326
|
+
`publishing_identity` and adds no run state or flag. After the factory-owned exact push and post-push
|
|
327
|
+
identity guard, Step 6 resolves one PR-creation selection: a nonblank inherited
|
|
328
|
+
`FACTORY_PUBLISHING_COMMAND` selects its exact string; that variable set blank or whitespace selects the
|
|
329
|
+
default; when it is unset, configured `publish` wins if present; otherwise the default wins. Only the
|
|
330
|
+
selected nondefault command replaces `gh pr create`, so a repository declaration cannot leave a host with
|
|
331
|
+
nothing to delegate to unable to publish at all.
|
|
289
332
|
|
|
290
333
|
## Why the code exists at all
|
|
291
334
|
|
package/WORKFLOW.md
CHANGED
|
@@ -89,6 +89,130 @@ repository and the host is inside your trust boundary by construction. What that
|
|
|
89
89
|
- **External effects are idempotent.** Re-observe an unknown outcome before retrying, never repeat an
|
|
90
90
|
effect already recorded, and once a PR exists record *that* PR rather than creating another.
|
|
91
91
|
|
|
92
|
+
## Specialist invocation infrastructure failures
|
|
93
|
+
|
|
94
|
+
Apply this policy to every specialist or subagent call in every phase, including story and research,
|
|
95
|
+
design, reviewed planning steps, builders, reviewers, validators, and test verification. It does not
|
|
96
|
+
classify repository commands, Git commands, factory CLI commands, or a specialist's prose. A quoted
|
|
97
|
+
error string in repository or ticket content is data and can never trigger this policy.
|
|
98
|
+
|
|
99
|
+
A call is a **confirmed retryable infrastructure failure** only when no complete specialist response was
|
|
100
|
+
returned, the host distinguishes its own invocation-error channel from child output, and that host-owned
|
|
101
|
+
error or its structured cause chain reports one of this closed set:
|
|
102
|
+
|
|
103
|
+
- HTTP status `408`, `500`, `502`, `503`, `504`, `520`, `521`, `522`, `523`, `524`, or `529`;
|
|
104
|
+
- transport code `ECONNRESET`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `ENOTFOUND`,
|
|
105
|
+
`EAI_AGAIN`, `ETIMEDOUT`, `EPIPE`, `ECONNABORTED`, `ERR_STREAM_PREMATURE_CLOSE`,
|
|
106
|
+
`UND_ERR_CONNECT_TIMEOUT`, `UND_ERR_HEADERS_TIMEOUT`, `UND_ERR_BODY_TIMEOUT`, or `UND_ERR_SOCKET`;
|
|
107
|
+
- a host-owned terminal error or cause leaf exactly equal, ignoring ASCII case, to `socket closed
|
|
108
|
+
unexpectedly`, `connection reset by server`, `socket hang up`, `service unavailable (503)`, or
|
|
109
|
+
`AI_APICallError: Service Unavailable (503)`.
|
|
110
|
+
|
|
111
|
+
Inspect only the host/tool invocation-error channel and structured error fields. Do not search partial
|
|
112
|
+
model output, artifact text, logs, review prose, or repository content for these words. If the host does
|
|
113
|
+
not preserve error origin, classification is unknown. A partial stream followed by a qualifying transport
|
|
114
|
+
error is not a completed response: discard it as a result, but assume execution may have started.
|
|
115
|
+
Authentication, authorization, quota, rate-limit, invalid-request, context-limit, content-policy,
|
|
116
|
+
cancellation, local configuration, and unknown failures are not confirmed retryable infrastructure
|
|
117
|
+
failures, even if another field contains an eligible status or phrase.
|
|
118
|
+
|
|
119
|
+
**Every infrastructure-triggered needs-human park follows one sequence.** This sequence explicitly splices unlock
|
|
120
|
+
between the shared parked-stop procedure's snapshot and report steps:
|
|
121
|
+
|
|
122
|
+
1. Quiesce every outstanding specialist, tool, and heartbeat call.
|
|
123
|
+
2. Preserve the current persisted attempt when the subject is budgeted; for an unbudgeted subject, create
|
|
124
|
+
no durable attempt or progress record.
|
|
125
|
+
3. Execute shared parked-stop step 1 with the exact reason token selected below.
|
|
126
|
+
4. Execute shared parked-stop step 2. Attempt the parked snapshot and retain its verified path or its
|
|
127
|
+
publication failure for the report.
|
|
128
|
+
5. Whether or not step 2 published a snapshot, release this driver's verified owning session and require
|
|
129
|
+
qualified status to show an absent lock and a null owner.
|
|
130
|
+
6. Only after unlock verification succeeds, execute shared parked-stop step 3 and report the retained
|
|
131
|
+
sandbox with the snapshot path or failure.
|
|
132
|
+
|
|
133
|
+
If release or unlock verification fails, do not execute shared step 3 and do not issue the normal
|
|
134
|
+
parked-success report. Report only `Outcome: retained-lock-error` with actual status, terminal result,
|
|
135
|
+
lock state, and error.
|
|
136
|
+
|
|
137
|
+
Each branch selects exactly one reason token and no other text. Never put the provider error, response
|
|
138
|
+
fragment, URL, credential, token, diagnostics, or any host-supplied string into it. Bind the selected
|
|
139
|
+
reason as `PRE_QUOTING_REASON` and transport it as the sole `--reason` argument with the deterministic
|
|
140
|
+
POSIX single-quote encoding defined under Publishing identity enforcement; the encoded token is never
|
|
141
|
+
persisted.
|
|
142
|
+
|
|
143
|
+
| branch token | exact persisted reason template |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `NON_RETRYABLE_REASON` | `specialist invocation failed with a non-retryable error for <role> on <subject>; inspect the host invocation log, then prove execution never started or recover the same invocation before continuing` |
|
|
146
|
+
| `UNKNOWN_OUTCOME_REASON` | `specialist infrastructure outcome unknown for <role> on <subject>; prove execution never started or recover the same invocation before continuing` |
|
|
147
|
+
| `SECOND_FAILURE_REASON` | `specialist infrastructure failed twice consecutively for <role> on <subject>; after provider or network recovery, prove execution never started or recover the same invocation before continuing` |
|
|
148
|
+
|
|
149
|
+
`<role>` is the exact canonical dispatch target, never agent frontmatter or host/provider text. Select
|
|
150
|
+
`<subject>` from this closed map:
|
|
151
|
+
|
|
152
|
+
| role | canonical subject |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `story-reader`, `story-writer` | `stage:story` |
|
|
155
|
+
| `codebase-researcher` | `stage:research` |
|
|
156
|
+
| `design-interpreter` | `stage:design` |
|
|
157
|
+
| `spec-writer` | `step:spec-writer` |
|
|
158
|
+
| `work-decomposer` | `step:work-decomposer` |
|
|
159
|
+
| `backend-builder`, `frontend-builder` | `slice:<slice-id>` |
|
|
160
|
+
| `test-verifier` | `step:test-verifier` |
|
|
161
|
+
| `implementation-validator` | `stage:implementation-validator` |
|
|
162
|
+
| `work-reviewer` | the exact reviewed subject: `step:spec-writer`, `step:work-decomposer`, `slice:<slice-id>`, or `step:test-verifier` |
|
|
163
|
+
|
|
164
|
+
For a reason template, render `<slice-id>` from at most the first 80 ASCII characters of the schema-valid
|
|
165
|
+
persisted slice ID and append `~` when truncated. The in-memory invocation key still uses the exact full
|
|
166
|
+
role, subject, and persisted attempt when one exists. Constants come only from the table and slice IDs
|
|
167
|
+
come only from `run.json`; neither field comes from host or provider diagnostics.
|
|
168
|
+
|
|
169
|
+
If an excluded or non-transport failure returns no complete response, preserve the persisted attempt when
|
|
170
|
+
one exists, create no attempt for unbudgeted work, and take the common infrastructure-park sequence with
|
|
171
|
+
`NON_RETRYABLE_REASON`. Do not retry it or treat it as rejected work.
|
|
172
|
+
|
|
173
|
+
For each active invocation key — exact specialist role, exact workflow subject or slice, and the current
|
|
174
|
+
persisted attempt number when that subject is budgeted — hold an in-memory consecutive-infrastructure-
|
|
175
|
+
failure count. Start it at zero in every new driver invocation, including after resume; never write it to
|
|
176
|
+
`run.json` or any artifact. A complete specialist response or a non-infrastructure outcome for that same
|
|
177
|
+
key resets it to zero. Activity for another key neither combines with nor resets it.
|
|
178
|
+
|
|
179
|
+
On the first confirmed failure for a key, increment only that in-memory count. Keep the control plane
|
|
180
|
+
unchanged: do not issue any step or slice transition with `--attempts N+1`, do not observe or review
|
|
181
|
+
partial output, and do not record `accepted`, `rejected`, or `blocked`. Continue automatically only
|
|
182
|
+
through one of these two safe paths:
|
|
183
|
+
|
|
184
|
+
1. When trusted host metadata proves execution never started, re-dispatch the exact same role, subject,
|
|
185
|
+
inputs and, when budgeted, the persisted attempt number.
|
|
186
|
+
2. When execution started or may have started, recover and continue the same host dispatch or child
|
|
187
|
+
session by its existing host-owned identity. First inspect that same child and its expected artifact or
|
|
188
|
+
worktree; never create a second child for the logical attempt and never repeat successful siblings in a
|
|
189
|
+
parallel wave.
|
|
190
|
+
|
|
191
|
+
If neither path is available, preserve the persisted attempt when one exists, create none for unbudgeted
|
|
192
|
+
work, and take the common infrastructure-park sequence with `UNKNOWN_OUTCOME_REASON`. An unbudgeted
|
|
193
|
+
research or design call still permits at most one safe same-invocation recovery and creates no durable
|
|
194
|
+
progress record.
|
|
195
|
+
|
|
196
|
+
On the second consecutive confirmed failure for the same key during that safe re-dispatch or recovery,
|
|
197
|
+
do not invoke it again. Take the common infrastructure-park sequence with `SECOND_FAILURE_REASON`.
|
|
198
|
+
|
|
199
|
+
The failure count remains invocation-local: every new driver, including one entered after explicit resume,
|
|
200
|
+
starts it at zero. That reset is not proof that prior execution did not start. When the preserved historical
|
|
201
|
+
terminal reason matches any infrastructure reason template, it guards the first later dispatch of the
|
|
202
|
+
named canonical role and subject at the retained attempt, or the named unbudgeted stage. Before that
|
|
203
|
+
dispatch, trusted host metadata must prove the prior invocation never started, or the driver must recover
|
|
204
|
+
and inspect that same host dispatch or child identity and its expected artifact or worktree. Explicit
|
|
205
|
+
resume, `status.next`, provider recovery, the reset count, and an operator assertion alone establish
|
|
206
|
+
neither fact. If neither safe path is available, do not dispatch; re-enter the common infrastructure-park
|
|
207
|
+
sequence with `UNKNOWN_OUTCOME_REASON`. Never repeat successful siblings.
|
|
208
|
+
|
|
209
|
+
A budgeted attempt advances only after a complete specialist response reaches the ordinary workflow and
|
|
210
|
+
that response is rejected on its merits or violates the specialist's required output contract. A complete
|
|
211
|
+
unbudgeted result returns to its ordinary workflow without creating an attempt. Infrastructure recovery is
|
|
212
|
+
the same attempt, not another use of `max_retries`. This rule is instruction rather than CLI enforcement:
|
|
213
|
+
the host owns specialist invocation errors, while the CLI continues to enforce every durable attempt
|
|
214
|
+
transition the driver actually records.
|
|
215
|
+
|
|
92
216
|
## The chain
|
|
93
217
|
|
|
94
218
|
```
|
|
@@ -304,9 +428,16 @@ Three properties make this safe to do at a park rather than only at completion:
|
|
|
304
428
|
failed would leave `status: running` with nothing alive, which every health signal misreads — a worse
|
|
305
429
|
outcome than a missing snapshot.
|
|
306
430
|
|
|
307
|
-
A snapshot is
|
|
308
|
-
|
|
309
|
-
|
|
431
|
+
A snapshot is a restore input, not a live run: resume still operates only on a sandbox manifest. While `S`
|
|
432
|
+
exists, never restore over it or treat the snapshot as authority over the live plane. If `S` is lost, fetch
|
|
433
|
+
the feature branch still advertised by the operator effective push endpoint into a full remote-tracking ref whose suffix is the recorded branch, then run
|
|
434
|
+
`factory restore "$R" --repo "$O" --from "$RESTORE_REF" --json`. Restore creates only the exact derived
|
|
435
|
+
sandbox, remains parked and lockless, aligns and rechecks the operator's effective push target, omits the
|
|
436
|
+
old plane-root lock, proves every preserved merged-slice Git and evidence binding, resets unrecoverable nonmerged slices to `pending`, and reports `reset_slices` and `invalidated`. It omits prior-generation
|
|
437
|
+
canonical verifier records and always invalidates Gate 3 and test-verifier state. It records its source inventory and restored commit at `status.restore`; `park_snapshot` becomes `null` because the new
|
|
438
|
+
generation is intentionally not byte-identical to its source. Review those losses, bind `RUN_REPO` to the
|
|
439
|
+
returned sandbox, and only then enter the ordinary claim-and-resume order. Do not publish snapshots for
|
|
440
|
+
`blocked` or `partial`, which are not resumable.
|
|
310
441
|
|
|
311
442
|
At every interactive gate, `changes: <feedback>` records `changes`, follows
|
|
312
443
|
`changes-at-gate:<name>`, revises only the affected stage, and re-presents it pending. `stop` requires
|
|
@@ -376,10 +507,13 @@ The optional repository-owned file is `$O/.factory.json`:
|
|
|
376
507
|
}
|
|
377
508
|
```
|
|
378
509
|
|
|
379
|
-
The root must be a JSON object with the
|
|
380
|
-
plus only the optional own properties `pr_draft`, `verify_timeout_ms`, `bootstrap`, and
|
|
510
|
+
The root must be a JSON object with the two required own properties `resolve` and `verify`,
|
|
511
|
+
plus only the optional own properties `publish`, `pr_draft`, `verify_timeout_ms`, `bootstrap`, and
|
|
381
512
|
`bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and `bootstrap` are command strings; every present
|
|
382
|
-
command must be non-empty.
|
|
513
|
+
command must be non-empty. `publish` was required and invoked nowhere until this release, so every
|
|
514
|
+
consumer wrote a command that could not run. It is optional now and contributes only the file candidate
|
|
515
|
+
to the one Step 6 publishing selection. Inherited `FACTORY_PUBLISHING_COMMAND` or the default may win, so
|
|
516
|
+
presence alone never executes this entry. There is no `publishing_identity` key: the account a run publishes as is a
|
|
383
517
|
property of the environment it runs in, not of the repository, and a tracked file cannot hold two values
|
|
384
518
|
for one repository published from both a maintainer's checkout and an automated host. A file carrying that
|
|
385
519
|
key is malformed, because the optional set above is closed. `pr_draft` must be a JSON boolean
|
|
@@ -388,7 +522,7 @@ safe integers when present, and `bootstrap_timeout_ms` is valid only with a decl
|
|
|
388
522
|
`verify_timeout_ms` and `bootstrap_timeout_ms` each independently default to `900000` milliseconds;
|
|
389
523
|
neither timeout shares or consumes the other's budget.
|
|
390
524
|
|
|
391
|
-
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`;
|
|
525
|
+
Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `pr_draft`; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; missing or invalid required entries; then invalid `publish`.
|
|
392
526
|
|
|
393
527
|
Do not use the obsolete summary “Validation refuses the first matching defect in this order: unreadable or invalid JSON, a non-object root, or unknown keys; invalid `bootstrap`; `bootstrap_timeout_ms` without `bootstrap`; invalid `bootstrap_timeout_ms`; invalid `verify_timeout_ms`; then missing or invalid required entries.” because it omits the earlier `pr_draft` check.
|
|
394
528
|
|
|
@@ -516,7 +650,7 @@ Do not create, write, merge, archive, or package `.factory.json`. It remains ope
|
|
|
516
650
|
committed, so every clone and sandbox carries it, and refused by the privileged-path policy, so a run
|
|
517
651
|
cannot widen its own configuration. It lived under the gitignored `.factory/` run directory until that proved unusable —
|
|
518
652
|
`.factory/` is gitignored, so the file could not be committed and never reached a sandbox clone, which
|
|
519
|
-
made the `verify` and
|
|
653
|
+
made the `verify` and `publish` entries unavailable and left this repository unable to resolve
|
|
520
654
|
a reference from a fresh checkout. For configured resolver execution, add no helper module,
|
|
521
655
|
command runner, parser service, plugin bridge, transport, protocol, or CLI command. Add no resolver
|
|
522
656
|
cache, payload handoff, manifest or session
|
|
@@ -534,11 +668,14 @@ separate capture policy, output channel, buffering, truncation, redaction, outpu
|
|
|
534
668
|
retry, or fallback after any configured resolver result or failure. The verify timeout and bounded retry
|
|
535
669
|
below apply only to repository `verify` shell attempts; the bootstrap timeout applies only to CLI-owned
|
|
536
670
|
init and explicit resume. Neither applies to `resolve`, slice observation, or Gate 3 commands. Do not
|
|
537
|
-
change platform placement, background-tool, title-association, host-session
|
|
671
|
+
change platform placement, background-tool, title-association, or host-session behavior. Publication changes only through the optional Step 6 command below.
|
|
538
672
|
`story-reader` remains lookup-free and capability-free beyond its existing generic read tools.
|
|
539
673
|
|
|
540
674
|
`resolve` and `verify` are consumed now, and the run's recorded `publishing_identity` is compared at the
|
|
541
|
-
guards below.
|
|
675
|
+
guards below. Step 6 resolves exactly one publishing selection from inherited
|
|
676
|
+
`FACTORY_PUBLISHING_COMMAND`, the optional `publish` entry, or the default, in the precedence defined
|
|
677
|
+
below. Only a selected nondefault command replaces the driver's `gh pr create`; the factory-owned exact
|
|
678
|
+
push and post-push identity guard remain unchanged.
|
|
542
679
|
|
|
543
680
|
Configured `bootstrap` is consumed only by CLI-owned fresh init and explicit resume; the workflow consumer validates it but never executes it itself.
|
|
544
681
|
|
|
@@ -548,7 +685,7 @@ Effective push-target capture and comparison are active through the package-owne
|
|
|
548
685
|
|---|---|---|---|---|
|
|
549
686
|
| `bootstrap` | Exact configured string as one shell command with `shell: true`, inherited environment and stdin, cwd exactly the selected sandbox, and child stdout and stderr both routed to CLI stderr. Each execution receives its own `bootstrap_timeout_ms`, independently `900000` when omitted. | Numeric exit status or unavailable `null`; output is visible on CLI stderr and never parsed | Clean zero succeeds; dirty or unobservable tracked state outranks unavailable or nonzero exit | Invoked by the CLI once during configured fresh init and again on every explicit configured resume; never invoked by resolver, merge verification or replay, direct repository verification, slice or Gate 3 observation, effective push, or publication. |
|
|
550
687
|
| `verify` | Ordinary shell step in the exact integration-worktree cwd with inherited environment; no structured stdin or factory-specific payload is defined. Each attempt receives the full configured `verify_timeout_ms`, silently `900000` when omitted. | Exit status is authoritative; stdout and stderr are inherited, informational, and unparsed | Zero means success; non-zero means repository verification failed; no numeric child status means unavailable | Invoked after each newly recorded merge through `observe --repository-verify`, with at most two executions in that merge invocation. The timeout and retry never apply to resolver, slice, or Gate 3 commands. |
|
|
551
|
-
| `publish` |
|
|
688
|
+
| `publish` | Optional file candidate for the one Step 6 publishing selection. A nonblank inherited `FACTORY_PUBLISHING_COMMAND` selects its exact string; the same variable set empty or to whitespace selects the default; when the variable is unset this entry is selected if present, otherwise the default. A selected nondefault command runs as one shell step in `RUN_REPO` cwd, with no stdin or positional arguments and inherited environment plus exact `PR_BASE`, `FEATURE_BRANCH`, `PR_DRAFT`, `PR_TITLE`, and absolute `PR_BODY_FILE`. | Exit status is authoritative; the last nonempty stdout line must be an absolute HTTPS URL and becomes `PR_URL` | Zero plus that URL is recordable; any other result is indeterminate and parks before `factory pr` | The resolved selection replaces only `gh pr create`, after the factory-owned exact push and post-push identity guard. `factory pr` is unchanged and still records the URL. |
|
|
552
689
|
| `publishing_identity` | No runtime input; read the value `status` reports for the run, recorded at init from `--publishing-identity` or the inherited `FACTORY_PUBLISHING_IDENTITY` | Exact case-sensitive string compared with the observed login | Absent at init refuses before any sandbox exists; mismatch or unobservable identity parks the run | Active at the three mandatory guards below; only a manifest written before 0.8.0 can report `null` and skip them. |
|
|
553
690
|
|
|
554
691
|
When both bootstrap keys are absent, init and resume are exact no-ops for bootstrap: no execution, manifest fields, output, or response-shape change.
|
|
@@ -656,7 +793,7 @@ intervene between the verified running/same-owner result and that guard, or betw
|
|
|
656
793
|
and reconciliation. A pre-0.8.0 manifest reporting `null` preserves the nine orders without adding an operation.
|
|
657
794
|
The refreshed workflow read belongs to order 7 verification, before this boundary.
|
|
658
795
|
|
|
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
|
|
796
|
+
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 uses only the newly qualified next action for workflow progress, but before its first matching specialist dispatch it must apply the preserved infrastructure-reason guard above; it never treats explicit resume or the count reset as no-start proof.
|
|
660
797
|
|
|
661
798
|
If resume refuses after claim or the run later reparks, quiesce builders, tools, specialist tasks, and
|
|
662
799
|
heartbeat loops. Qualify the intended retained run again before reporting the stop. If it is still parked
|
|
@@ -1393,9 +1530,14 @@ Per slice:
|
|
|
1393
1530
|
$ factory slice "$R" "$SLICE_ID" review --evidence-ref "evidence/$SLICE_ID.json" \
|
|
1394
1531
|
--review-ref "reviews/$SLICE_ID.json" --repo "$RUN_REPO"
|
|
1395
1532
|
```
|
|
1396
|
-
- On REJECT, before spending an attempt, identify the
|
|
1533
|
+
- On REJECT, before spending an attempt, identify the cause of the remaining failures. If the fix would
|
|
1397
1534
|
violate an approved story or brief constraint, or repeated findings trace to the same unresolved
|
|
1398
1535
|
design choice, stop and escalate the smallest decision needed rather than burning attempts.
|
|
1536
|
+
When repeated reviews leave substantial acceptance gaps, review prior verified progress and identify
|
|
1537
|
+
a bounded, achievable remediation target for the next attempt, even when the design is fully decided.
|
|
1538
|
+
If no such target can be identified, park through the existing parked-stop procedure for replanning
|
|
1539
|
+
or operator clarification; preserve the work and do not silently change approved acceptance criteria.
|
|
1540
|
+
Unchanged finding counts alone are not a stall: progress can occur within a category that remains open.
|
|
1399
1541
|
Otherwise route the fixes back to that builder and re-observe. After `max_retries`, mark the slice
|
|
1400
1542
|
`blocked` and stop dispatching its dependents.
|
|
1401
1543
|
5. **Merge (you, serially)** — on APPROVE, merge the slice branch into the feature branch one at a
|
|
@@ -1752,16 +1894,27 @@ HEAD, a branch name, or an unpersisted variable.
|
|
|
1752
1894
|
is not a slice and has no slice `test_plan`, so it continues to supply its integration command. There
|
|
1753
1895
|
is no waiver: the stage exists to run the tests, so the evidence must record an observed run that
|
|
1754
1896
|
exited zero, against the integration head as it stands. Then `work-reviewer` confirms each criterion
|
|
1755
|
-
maps to a real assertion.
|
|
1897
|
+
maps to a real assertion, judging the whole integrated diff rather than any one slice's.
|
|
1898
|
+
On a single-slice run this is the last review before publication, and its production findings have no
|
|
1899
|
+
in-band repair: post-merge repair is test-only and a merged slice is never dispatched again, so a
|
|
1900
|
+
production defect parks the run for an operator instead of returning to a builder. Review it as the
|
|
1901
|
+
final reading it is.
|
|
1756
1902
|
This Gate 3 observation is always fresh and independent in the ordinary path. It uses the existing
|
|
1757
1903
|
argv-tokenized `--test-cmd` path and overwrites canonical evidence at the current head. The sole
|
|
1758
1904
|
substitution is a qualifying explicit repair re-verification pass at current HEAD under Gate 3's
|
|
1759
1905
|
complete inventory rules below; failed ordinary evidence remains preserved rather than overwritten.
|
|
1760
1906
|
2. `implementation-validator` — the holistic pass across the whole diff, complementing per-slice
|
|
1761
1907
|
reviews. **Skip it when the run has exactly one slice**: its subject is the interaction *between*
|
|
1762
|
-
slices, and with one there is none, so it
|
|
1763
|
-
a
|
|
1764
|
-
|
|
1908
|
+
slices, and with one there is none, so it has no question of its own to answer. Gate 3 does not
|
|
1909
|
+
require a verdict for a single-slice run. Run it for every multi-slice run; the gate refuses
|
|
1910
|
+
without it.
|
|
1911
|
+
|
|
1912
|
+
**Skipping it does not mean the integrated diff goes unread**, and nothing here should be read as
|
|
1913
|
+
saying a second reading is worthless. Step 1's `work-reviewer` pass over `test-verifier` judges that
|
|
1914
|
+
diff whole, and on a single-slice run it is the only review after the slice's own: mimir's
|
|
1915
|
+
chainlink-1304 merged its one slice clean at zero findings, and that pass then found two production
|
|
1916
|
+
defects in it, both real and both confirmed. What the skip removes is a duplicate *verdict* on a
|
|
1917
|
+
subject that does not exist, not the reading.
|
|
1765
1918
|
|
|
1766
1919
|
When you do run it, it returns GO / GO-WITH-NITS / NO-GO **and writes `reviews/implementation-validator.json`
|
|
1767
1920
|
naming the commit it judged**, exactly like any other reviewer. Then:
|
|
@@ -1973,17 +2126,60 @@ gh api --method GET /user --jq .login
|
|
|
1973
2126
|
factory pr "$R" --url "$PR_URL" --repo "$RUN_REPO"
|
|
1974
2127
|
```
|
|
1975
2128
|
|
|
1976
|
-
The
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2129
|
+
The fully qualified `git push` above is factory-owned and unchanged for every resolved publishing
|
|
2130
|
+
selection. It is the only push in this procedure. The second identity observation always runs after that
|
|
2131
|
+
push is known successful and immediately before the selected pull-request operation, with no intervening
|
|
2132
|
+
operation.
|
|
2133
|
+
|
|
2134
|
+
**Resolve one publishing selection before running anything, and execute that selection rather than
|
|
2135
|
+
either source.** In order: inherited `FACTORY_PUBLISHING_COMMAND` holding at least one non-whitespace
|
|
2136
|
+
character selects that string; the same variable set empty or to whitespace selects the default, even
|
|
2137
|
+
when `$O/.factory.json` declares `publish`; an unset variable selects the configured `publish` when the
|
|
2138
|
+
file declares one; and with neither, the default. The resolution runs whether or not `$O/.factory.json`
|
|
2139
|
+
exists, so an environment-only override selects a command in a repository that declares none, and a
|
|
2140
|
+
declared `publish` is never executed while that variable holds a different value. Nothing downstream
|
|
2141
|
+
reads either source again.
|
|
2142
|
+
|
|
2143
|
+
The environment overrides the file because how a run publishes is a property of the environment as much
|
|
2144
|
+
as of the repository -- the same reason there is no `publishing_identity` key in that file, and one
|
|
2145
|
+
repository is published from both a maintainer's checkout and an automated host. Empty selecting the
|
|
2146
|
+
default is what keeps a repository from declaring its way into a run that cannot publish at all from a
|
|
2147
|
+
host with nothing to delegate to, and it makes empty and absent behave alike rather than needing two
|
|
2148
|
+
rules. The override removes no guard: every run with a non-null recorded `publishing_identity` runs all
|
|
2149
|
+
three identity guards whether or not `$O/.factory.json` exists. Only a legacy manifest reporting `null`
|
|
2150
|
+
skips them. Report which source the selection came from, since the sources are indistinguishable afterwards and
|
|
2151
|
+
an operator debugging a publication needs to know which one ran.
|
|
2152
|
+
|
|
2153
|
+
**When the resolution selects a command rather than the default, run that exact selected string instead
|
|
2154
|
+
of only `gh pr create` above**,
|
|
2155
|
+
as one shell command in `RUN_REPO` cwd with no stdin or positional arguments. Add exactly five values to
|
|
2156
|
+
the inherited environment: exact `PR_BASE`, exact `FEATURE_BRANCH`, `PR_DRAFT` as `true` or `false`, exact
|
|
2157
|
+
decorated `TITLE` as `PR_TITLE`, and an absolute `PR_BODY_FILE` naming the exact decorated body bytes.
|
|
2158
|
+
Read the last nonempty stdout line as `PR_URL` and require it to be an absolute HTTPS URL. The selected
|
|
2159
|
+
nondefault command owns PR creation, but these inputs preserve the recorded base, head, mode, title, and body intent;
|
|
2160
|
+
do not claim the factory verified that the command honored them. `factory pr` still records the returned
|
|
2161
|
+
URL. Exit zero **with** that absolute HTTPS URL is the only recordable result. A non-zero exit, or a zero
|
|
2162
|
+
exit whose last line is not a URL, is indeterminate: do not claim that no external effect occurred,
|
|
2163
|
+
do not run `factory pr`, and do not fall back to `gh pr create`. Bind `PRE_QUOTING_REASON` exactly to
|
|
2164
|
+
`selected publishing command outcome indeterminate; re-observe whether the pull request exists before retry`;
|
|
2165
|
+
persist no other reason text. Never append or interpolate stdout, stderr, exit status or status text, URLs,
|
|
2166
|
+
credentials, tokens, provider diagnostics, or any other command-supplied text. Follow the common quiesce,
|
|
2167
|
+
park, durable-reason transport, owning release, unlock-verification, retention, reporting, and later-driver
|
|
2168
|
+
procedure. Before any retry, re-observe whether the pull request exists and record an existing one rather
|
|
2169
|
+
than creating another.
|
|
2170
|
+
|
|
2171
|
+
When the recorded identity is non-null, both Step 6 identity guards run for every resolved selection and
|
|
2172
|
+
whether or not `.factory.json` exists; only a legacy recorded `null` skips them. A mismatch or unobservable
|
|
2173
|
+
result follows the common quiesce, park, durable-reason, owning release, unlock-verification, retention,
|
|
2174
|
+
reporting, and later-driver procedure above. There is no separate identity guard before `factory pr`;
|
|
2175
|
+
preserve that command and every existing publication mode, status, and gate exactly.
|
|
2176
|
+
|
|
2177
|
+
The selected PR-creation operation is the orchestrator's external effect; the package makes no forge call
|
|
2178
|
+
and `factory pr` does not verify the forge's base. For a legacy manifest where
|
|
2179
|
+
`pr_base` is absent or null, stop and require a human/operator to choose or confirm the exact target,
|
|
2180
|
+
then pass that value through `gh pr create --base` or the selected nondefault command's exact `PR_BASE`.
|
|
2181
|
+
Never
|
|
2182
|
+
infer it from HEAD, the feature branch, repository or forge defaults, and
|
|
1987
2183
|
never backfill the legacy manifest.
|
|
1988
2184
|
|
|
1989
2185
|
`pr_url` is immutable once recorded — a run has one PR, and overwriting the URL would hide a second
|
|
@@ -2154,6 +2350,12 @@ accepted NO-GO findings, recorded overrides, retained or residual sandboxes, or
|
|
|
2154
2350
|
|
|
2155
2351
|
## Resuming
|
|
2156
2352
|
|
|
2353
|
+
If the intended sandbox is absent but qualified operator inspection finds the canonical parked snapshot,
|
|
2354
|
+
restore it first using the exact procedure above. Restore is not resume: it leaves the new generation
|
|
2355
|
+
parked with no owner and may reset or invalidate state. Never substitute a local branch, a bare commit, a
|
|
2356
|
+
new run, or a hand-copied manifest for the full remote-tracking ref and CLI command. After restore, use its
|
|
2357
|
+
returned sandbox as `RUN_REPO`, inspect `status.restore`, and start the same ownership sequence below.
|
|
2358
|
+
|
|
2157
2359
|
On invocation, if the run directory exists and you hold or steal the lock, the preserved compatibility
|
|
2158
2360
|
claim reads “run `factory status <run-id> --json` and resume; never restart.” It names a non-runnable
|
|
2159
2361
|
command stem. Execute only `factory status "$R" --json --repo "$RUN_REPO"`, then continue from `next`:
|
package/agents/story-writer.md
CHANGED
|
@@ -26,6 +26,14 @@ A feature idea in the engineer's words, plus (optionally) a research map from co
|
|
|
26
26
|
- Acceptance criteria are **testable**: each one is something test-verifier could later assert. "Works well" is not a criterion; "Auditor sees a disabled Save button until all required fields are filled" is.
|
|
27
27
|
- State what's **out of scope** explicitly — it's the cheapest way to prevent scope creep downstream.
|
|
28
28
|
- Keep it product-level. No file paths, no class names — that's the spec-writer's job.
|
|
29
|
+
- Each acceptance criterion must support the requested outcome or a necessary correctness/safety condition.
|
|
30
|
+
Label additional capabilities and broad architectural requirements as proposed scope additions,
|
|
31
|
+
explain why they are needed, and obtain explicit approval at the existing story gate before
|
|
32
|
+
incorporating them into accepted scope. Do not silently turn implementation preferences into requirements.
|
|
33
|
+
This is not a criterion-count limit or a reason to omit necessary reliability or safety behavior.
|
|
34
|
+
- Scope correctness and safety criteria to the requested behavior. When addressing known defects,
|
|
35
|
+
name the failure scenarios to prevent rather than silently generalizing them into a subsystem-wide guarantee.
|
|
36
|
+
If a broader guarantee is necessary, explain its scope and proof obligations before approval.
|
|
29
37
|
|
|
30
38
|
## Output contract
|
|
31
39
|
|
package/agents/work-reviewer.md
CHANGED
|
@@ -101,6 +101,11 @@ When the subject is a **class-wide** requirement — one that **cannot be establ
|
|
|
101
101
|
catch any of this; the two slices share no path. It is decidable from the plan alone, so check it here
|
|
102
102
|
rather than discovering it at the slice that fails.
|
|
103
103
|
- **Doc steps (`spec-writer`, `work-decomposer`):** every required field of the output contract is filled; the artifact is consistent with its inputs (does the brief cover every AC and match the research map's real paths? does the slice DAG obey file-disjoint + hotspot-serialization rules, and does every AC map to a slice?). For `work-decomposer`, do not approve unless the supplied `plan/slices.json` is a top-level object with array-valued `slices` (the exact seedable shape `{ "slices": [...] }`); inspect only the supplied artifact, not a broader plan schema. Coverage is not enough: where more than one slice exists, a slice claiming the entire acceptance set, or claiming paths spanning every module in its lane, is a BLOCKER — that plan reviews clean and fails later as "N categories missing", which is a scope report rather than a defect report. Apply the same reviewability test to **every** slice with no condition on the plan's size, including the only slice of a one-slice plan: if your own likely rejection of it would say whole categories of required behaviour remain unimplemented, it is too large and the plan is a BLOCKER. **A one-slice plan whose `### Single-slice justification` is absent, or which justifies itself only by the brief presenting one closed inventory, is a BLOCKER** — the justification must name the semantic boundaries considered and why splitting them would create proof-only work, illegal path/test ownership, or inseparable implementation. Do not demand a split from a genuinely small change whose justification does that; the requirement is a stated reason, not a minimum slice count. For class-wide subjects, the brief must include the finite implementation matrix (per §"Class-wide completeness") — a class-wide spec that lacks it is a BLOCKER. Whether each slice can make its own `test_plan` green is the satisfiability bullet's check, stated once there rather than repeated here.
|
|
104
|
+
Check that criteria and proposed requirements support the requested outcome or necessary correctness/safety.
|
|
105
|
+
Additional capabilities and broad architectural requirements need an explicit scope rationale and approval;
|
|
106
|
+
reject unapproved scope expansion rather than silently accepting it as an implementation requirement.
|
|
107
|
+
Check the supplied request and approval record; do not reopen explicitly approved scope merely because
|
|
108
|
+
a smaller feature is possible. Do not impose a criterion-count limit or reject necessary reliability behavior.
|
|
104
109
|
- **Build slices (`backend-builder` / `frontend-builder`):** apply the repo's own rubric — its agent instructions (`AGENTS.md` or `CLAUDE.md` and any review or rules files it points at — against the **observed diff**:
|
|
105
110
|
- Backend: the repo's layering, its projection/read path, its API boundary.
|
|
106
111
|
- Frontend: the repo's component conventions, binding forms, state approach and design tokens.
|
|
@@ -116,7 +121,7 @@ When the subject is a **class-wide** requirement — one that **cannot be establ
|
|
|
116
121
|
docs-only slice reviewed against tests it was ratified not to have is rejected forever; that
|
|
117
122
|
exemption is a plan decision, not yours to re-open here. It waives **test execution only**: the
|
|
118
123
|
acceptance must still be implemented and the diff must still be observed.
|
|
119
|
-
- **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.
|
|
124
|
+
- **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. Because this is the last reading of the integrated diff before publication, make its findings exhaustive in one pass: a production defect recorded here parks the run for an operator rather than returning to a builder, since post-merge repair is test-only and a merged slice is never dispatched again, so there is no later round to carry a withheld finding into.
|
|
120
125
|
|
|
121
126
|
## Security proportionality
|
|
122
127
|
|
package/bin/factory.js
CHANGED
|
@@ -21,6 +21,7 @@ import { writeProtectedFileAtomic, writeProtectedJsonAtomic } from "../core/atom
|
|
|
21
21
|
import { enforceEffectivePushTarget } from "../core/effective-push.js";
|
|
22
22
|
import { resolveSpawnExecutable } from "../core/executable.js";
|
|
23
23
|
import { dispatchInitPublication } from "./init-publication.js";
|
|
24
|
+
import { dispatchRestore, readRestoreRecord } from "./restore.js";
|
|
24
25
|
import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, repositoryRelativePath, validateRun } from "../state/schema.js";
|
|
25
26
|
import {
|
|
26
27
|
claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SESSION_LOCK_FILE, SessionLockHeldError,
|
|
@@ -35,6 +36,7 @@ export const COMMANDS = Object.freeze({
|
|
|
35
36
|
status: Object.freeze(["--repo", "--json"]),
|
|
36
37
|
"amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
|
|
37
38
|
resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
|
|
39
|
+
restore: Object.freeze(["--repo", "--from", "--now", "--json"]),
|
|
38
40
|
decide: Object.freeze(["--repo", "--text", "--session", "--now", "--json"]),
|
|
39
41
|
// No --force: `lock <id> steal` is the same operation with a name that says what it
|
|
40
42
|
// does, and two spellings of "take someone else's lock" is one too many.
|
|
@@ -331,6 +333,7 @@ function bootstrapOutcome(worktree, config, phase) {
|
|
|
331
333
|
return { exit, refusal };
|
|
332
334
|
}
|
|
333
335
|
|
|
336
|
+
|
|
334
337
|
function branchPoint(run) {
|
|
335
338
|
const base = run.slices.find((slice) => Array.isArray(slice.depends_on) && slice.depends_on.length === 0)?.base_ref;
|
|
336
339
|
if (!/^[0-9a-f]{40}$/u.test(base ?? "")) throw new CliError("first seeded root slice has no immutable 40-character base_ref");
|
|
@@ -351,6 +354,7 @@ async function writeObservedEvidence({ runDir, runId, subject, attempt, branch,
|
|
|
351
354
|
return { evidence, ancestry };
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
|
|
354
358
|
function canonicalRepositoryVerifyEvidence(evidence, { runId, run, integration, verifyCommand }) {
|
|
355
359
|
const baseRef = branchPoint(run);
|
|
356
360
|
const keys = Object.keys(evidence).sort();
|
|
@@ -497,6 +501,8 @@ async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit }) {
|
|
|
497
501
|
}
|
|
498
502
|
|
|
499
503
|
const HANDLERS = {
|
|
504
|
+
restore: dispatchRestore,
|
|
505
|
+
|
|
500
506
|
async ["reverify-repair"](positional, flags) {
|
|
501
507
|
if (positional.length !== 2) throw new CliError("factory reverify-repair requires exactly <run-id> <repair-record-id>");
|
|
502
508
|
const [runId, recordId] = positional;
|
|
@@ -1015,6 +1021,7 @@ const HANDLERS = {
|
|
|
1015
1021
|
max_retries: run.max_retries,
|
|
1016
1022
|
publishing_identity: run.publishing_identity ?? null,
|
|
1017
1023
|
park_snapshot: run.status === "needs-human" ? observedParkSnapshot(resolve(flags.repo ?? process.cwd()), runId, runDir) : null,
|
|
1024
|
+
restore: readRestoreRecord(runDir, run),
|
|
1018
1025
|
pr_draft: run.pr_draft ?? true,
|
|
1019
1026
|
lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
|
|
1020
1027
|
lock_session: lock.owner?.session ?? null,
|
|
@@ -1657,6 +1664,7 @@ function usage() {
|
|
|
1657
1664
|
factory amend-paths <run-id> <slice-id> --add PATH [--add PATH ...] --reason TEXT --session ID [--now ISO]
|
|
1658
1665
|
factory decide <run-id> --text TEXT --session ID [--now ISO]
|
|
1659
1666
|
factory resume <run-id> --session ID [--now ISO]
|
|
1667
|
+
factory restore <run-id> --repo OPERATOR --from refs/remotes/REMOTE/BRANCH [--now ISO]
|
|
1660
1668
|
factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
|
|
1661
1669
|
factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
|
|
1662
1670
|
factory heartbeat <run-id> --session ID
|
package/bin/restore.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// False-green enforcement throughout: restore publishes preserved approvals and merged claims only after
|
|
2
|
+
// the snapshot, pushed ref, Git bindings, copy, destination, and final manifest commit are re-observed.
|
|
3
|
+
// Active work without those proofs is reset and reported instead of being presented as recovered.
|
|
4
|
+
import { constants, chmodSync, copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
|
|
7
|
+
import { CONTROL_PLANE, validateRun } from "../state/schema.js";
|
|
8
|
+
import { git, observeWorktree, privilegedPaths, proveInitContainment, unownedPaths } from "../observe/index.js";
|
|
9
|
+
import { isApproving, observeMergeProof, readEvidence, readReview } from "../observe/review.js";
|
|
10
|
+
import { capturePushTarget, enforceEffectivePushTarget } from "../core/effective-push.js";
|
|
11
|
+
import { writeProtectedJsonAtomic } from "../core/atomic-write.js";
|
|
12
|
+
|
|
13
|
+
const ID = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u;
|
|
14
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
15
|
+
const RESTORE_REF = "restore.json";
|
|
16
|
+
const REVIEWED_STEPS = new Set(["spec-writer", "work-decomposer"]);
|
|
17
|
+
const SKIPPED_ENTRIES = new Set(["factory.lock", "run.json", "evidence/test-verifier.json", "reviews/test-verifier.json"]);
|
|
18
|
+
|
|
19
|
+
class RestoreError extends Error { constructor(message, options) { super(message, options); this.name = "RestoreError"; } }
|
|
20
|
+
|
|
21
|
+
function exactDirectory(path, description) {
|
|
22
|
+
let stat;
|
|
23
|
+
try { stat = lstatSync(path); } catch (error) { throw new RestoreError(`${description} '${path}' is not observable`, { cause: error }); }
|
|
24
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || realpathSync(path) !== path) throw new RestoreError(`${description} '${path}' is not an exact canonical directory`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function entryState(path) { try { return lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } }
|
|
28
|
+
|
|
29
|
+
function inventoryEntries(root, skipped = new Set()) {
|
|
30
|
+
const entries = [];
|
|
31
|
+
const visit = (relative, full) => {
|
|
32
|
+
if (skipped.has(relative)) return;
|
|
33
|
+
const stat = lstatSync(full), mode = (stat.mode & 0o7777).toString(8);
|
|
34
|
+
if (stat.isSymbolicLink()) entries.push(`${relative} l ${mode} ${readlinkSync(full)}`);
|
|
35
|
+
else if (stat.isDirectory()) {
|
|
36
|
+
entries.push(`${relative} d ${mode}`);
|
|
37
|
+
for (const name of readdirSync(full)) visit(relative === "." ? name : `${relative}/${name}`, join(full, name));
|
|
38
|
+
} else if (stat.isFile()) entries.push(`${relative} f ${mode} ${createHash("sha256").update(readFileSync(full)).digest("hex")}`);
|
|
39
|
+
else throw new RestoreError(`park snapshot contains unsupported entry type at '${full}'`);
|
|
40
|
+
};
|
|
41
|
+
visit(".", root);
|
|
42
|
+
return entries.sort();
|
|
43
|
+
}
|
|
44
|
+
const inventory = (root, skipped = new Set()) => JSON.stringify(inventoryEntries(root, skipped));
|
|
45
|
+
|
|
46
|
+
function copySnapshot(source, target) {
|
|
47
|
+
const copy = (relative, from, to) => {
|
|
48
|
+
if (SKIPPED_ENTRIES.has(relative)) return;
|
|
49
|
+
const stat = lstatSync(from), mode = stat.mode & 0o7777, present = entryState(to);
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
if (present && (present.isSymbolicLink() || !present.isDirectory())) throw new RestoreError(`restore target '${to}' has an unsafe type`);
|
|
52
|
+
if (!present) mkdirSync(to);
|
|
53
|
+
for (const name of readdirSync(from)) copy(relative === "." ? name : `${relative}/${name}`, join(from, name), join(to, name));
|
|
54
|
+
chmodSync(to, mode);
|
|
55
|
+
} else if (present) throw new RestoreError(`restore target '${to}' already exists`);
|
|
56
|
+
else if (stat.isSymbolicLink()) {
|
|
57
|
+
const link = readlinkSync(from), lexical = relativePath(source, resolve(dirname(from), link));
|
|
58
|
+
let referent;
|
|
59
|
+
try { referent = relativePath(source, realpathSync(from)); } catch { throw new RestoreError(`park snapshot symlink '${from}' has an unprovable referent`); }
|
|
60
|
+
if (isAbsolute(link) || [lexical, referent].some((path) => path === ".." || path.startsWith(`..${sep}`) || path.startsWith(sep))) throw new RestoreError(`park snapshot symlink '${from}' escapes the control plane`);
|
|
61
|
+
symlinkSync(link, to);
|
|
62
|
+
} else if (stat.isFile()) { copyFileSync(from, to, constants.COPYFILE_EXCL); chmodSync(to, mode); }
|
|
63
|
+
else throw new RestoreError(`park snapshot contains unsupported entry type at '${from}'`);
|
|
64
|
+
};
|
|
65
|
+
copy(".", source, target);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function exactOid(repository, ref) {
|
|
69
|
+
const result = git(repository, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
|
|
70
|
+
return result.status === 0 && /^[0-9a-f]{40}\n$/u.test(result.stdout) ? result.stdout.slice(0, -1) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertCommit(repository, sha, description, head) {
|
|
74
|
+
if (!SHA.test(String(sha)) || exactOid(repository, sha) !== sha) throw new RestoreError(`${description} '${sha}' does not resolve in the restored repository`);
|
|
75
|
+
const ancestry = git(repository, ["merge-base", "--is-ancestor", sha, head]);
|
|
76
|
+
if (ancestry.status !== 0) throw new RestoreError(`${description} '${sha}' is not an ancestor of restored feature head '${head}'`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertRegularRecord(runDir, ref, description) {
|
|
80
|
+
const path = resolve(runDir, ref), rel = relativePath(runDir, path), stat = entryState(path);
|
|
81
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith(sep)
|
|
82
|
+
|| !stat?.isFile() || stat.isSymbolicLink() || realpathSync(path) !== path) {
|
|
83
|
+
throw new RestoreError(`${description} '${ref}' must be a contained regular file in the park snapshot`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function jsonAt(runDir, ref, description) {
|
|
88
|
+
assertRegularRecord(runDir, ref, description);
|
|
89
|
+
try { return JSON.parse(readFileSync(join(runDir, ref), "utf8")); } catch (error) { throw new RestoreError(`${description} '${ref}' could not be read`, { cause: error }); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeReview(runDir, ref) { assertRegularRecord(runDir, ref, "review"); return readReview(runDir, ref); }
|
|
93
|
+
function safeEvidence(runDir, ref, runId) { assertRegularRecord(runDir, ref, "evidence"); return readEvidence(runDir, ref, { runId }); }
|
|
94
|
+
|
|
95
|
+
function transformRun(source, at, head, worktree, sourceRunDir) {
|
|
96
|
+
const resetSlices = [];
|
|
97
|
+
const slices = source.slices.map((slice) => {
|
|
98
|
+
if (slice.status === "merged") return { ...slice, worktree: null };
|
|
99
|
+
if (slice.status !== "pending" || [slice.worktree, slice.branch, slice.base_ref, slice.evidence_ref, slice.review_ref, slice.merge_commit].some((value) => value !== null)) resetSlices.push(slice.id);
|
|
100
|
+
return { ...slice, status: "pending", worktree: null, branch: null, base_ref: null, evidence_ref: null, review_ref: null, merge_commit: null };
|
|
101
|
+
});
|
|
102
|
+
const invalidated = [];
|
|
103
|
+
const verifierState = source.steps.some((step) => step.agent === "test-verifier" && (step.status === "accepted" || step.review_ref || step.evidence_ref))
|
|
104
|
+
|| ["evidence/test-verifier.json", "reviews/test-verifier.json"].some((ref) => entryState(join(sourceRunDir, ref)));
|
|
105
|
+
const gates = structuredClone(source.gates);
|
|
106
|
+
if (gates.pre_pr?.status === "approved") {
|
|
107
|
+
gates.pre_pr = { ...gates.pre_pr, status: "pending", at: null, reviewed_head: null };
|
|
108
|
+
invalidated.push("gate:pre_pr");
|
|
109
|
+
}
|
|
110
|
+
let validator = source.validator;
|
|
111
|
+
if (validator && validator.reviewed_head !== head) { validator = null; invalidated.push("validator"); }
|
|
112
|
+
if (verifierState) invalidated.push("step:test-verifier");
|
|
113
|
+
const steps = source.steps.map((step) => step.agent !== "test-verifier" ? step : {
|
|
114
|
+
...step, status: step.status === "accepted" ? "running" : step.status, review_ref: null, evidence_ref: null,
|
|
115
|
+
});
|
|
116
|
+
return { run: validateRun({ ...source, worktree, updated_at: at, slices, gates, steps, validator }), resetSlices, invalidated };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertPlanBinding(runDir, run) {
|
|
120
|
+
if (run.slices.length === 0 && !run.plan_digest) return; assertRegularRecord(runDir, "plan/slices.json", "ratified slice plan");
|
|
121
|
+
const bytes = readFileSync(join(runDir, "plan/slices.json")), plan = JSON.parse(bytes);
|
|
122
|
+
if (`sha256:${createHash("sha256").update(bytes).digest("hex")}` !== run.plan_digest) throw new RestoreError("restored slice plan does not match the Brief-approved digest"); if (run.slices.length === 0) return;
|
|
123
|
+
const project = (slice) => ({ id: slice.id, stack: slice.stack, depends_on: slice.depends_on ?? [], paths: slice.paths, test_plan: slice.test_plan });
|
|
124
|
+
const ratified = Array.isArray(plan.slices) && plan.slices.length === run.slices.length && plan.slices.map((slice, index) => project({ ...slice, paths: [...slice.paths, ...(run.slices[index].path_amendments ?? []).flatMap((item) => item.added_paths)] }));
|
|
125
|
+
if (!ratified || JSON.stringify(ratified) !== JSON.stringify(run.slices.map(project))) throw new RestoreError("restored slices do not match the Brief-ratified plan and amendments");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertPreservedBindings(sourceRunDir, run, repository, head) {
|
|
129
|
+
for (const slice of run.slices.filter((entry) => entry.status === "merged")) {
|
|
130
|
+
assertCommit(repository, slice.base_ref, `slice '${slice.id}' base_ref`, head);
|
|
131
|
+
assertCommit(repository, slice.merge_commit, `slice '${slice.id}' merge_commit`, head);
|
|
132
|
+
const review = safeReview(sourceRunDir, slice.review_ref);
|
|
133
|
+
const evidence = safeEvidence(sourceRunDir, slice.evidence_ref, run.run_id);
|
|
134
|
+
if (review.subject !== slice.id || review.attempt !== slice.attempts || !isApproving(review.verdict)) throw new RestoreError(`slice '${slice.id}' review does not approve its restored attempt`);
|
|
135
|
+
if (evidence.subject !== slice.id || evidence.attempt !== slice.attempts || evidence.review_ready !== true
|
|
136
|
+
|| evidence.base_ref !== slice.base_ref || evidence.commit !== review.reviewed_commit) {
|
|
137
|
+
throw new RestoreError(`slice '${slice.id}' evidence does not bind its restored base, attempt, and approved commit`);
|
|
138
|
+
}
|
|
139
|
+
for (const [name, sha] of [["reviewed_commit", review.reviewed_commit], ["evidence base_ref", evidence.base_ref], ["evidence commit", evidence.commit]]) assertCommit(repository, sha, `slice '${slice.id}' ${name}`, head);
|
|
140
|
+
if (git(repository, ["merge-base", "--is-ancestor", slice.base_ref, review.reviewed_commit]).status !== 0) throw new RestoreError(`slice '${slice.id}' base is not an ancestor of its reviewed commit`);
|
|
141
|
+
const observed = observeWorktree(repository, slice.base_ref, { ref: review.reviewed_commit }), files = [...observed.files_changed].sort();
|
|
142
|
+
if (!observed.diff_observed || files.length === 0 || JSON.stringify(files) !== JSON.stringify([...evidence.files_changed].sort()) || unownedPaths(files, slice.paths).length || privilegedPaths(files).length) throw new RestoreError(`slice '${slice.id}' restored diff violates its evidence or ratified path ownership`);
|
|
143
|
+
const tests = evidence.tests, skip = `test_plan for '${slice.id}' was approved empty at slices-seed`;
|
|
144
|
+
if (slice.test_plan.length ? !(tests?.observed === true && tests.exit === 0 && slice.test_plan.includes(tests.cmd))
|
|
145
|
+
: !(tests?.observed === false && tests.exit === null && tests.skipped_reason === skip)) throw new RestoreError(`slice '${slice.id}' evidence does not satisfy its ratified test_plan`);
|
|
146
|
+
const proof = observeMergeProof(repository, { baseRef: slice.base_ref, reviewedCommit: review.reviewed_commit, mergeCommit: slice.merge_commit });
|
|
147
|
+
if (!proof.proven) throw new RestoreError(`slice '${slice.id}' merge proof failed after restore: ${proof.reason}`);
|
|
148
|
+
}
|
|
149
|
+
for (const step of run.steps.filter((entry) => entry.status === "accepted" && REVIEWED_STEPS.has(entry.agent))) {
|
|
150
|
+
if (!step.review_ref) throw new RestoreError(`accepted step '${step.agent}' has no review binding`);
|
|
151
|
+
const review = safeReview(sourceRunDir, step.review_ref);
|
|
152
|
+
if (review.subject !== step.agent || review.attempt !== step.attempts || !isApproving(review.verdict)) throw new RestoreError(`step '${step.agent}' review does not approve its restored attempt`);
|
|
153
|
+
}
|
|
154
|
+
for (const [name, gate] of Object.entries(run.gates)) if (gate.reviewed_head) assertCommit(repository, gate.reviewed_head, `gate '${name}' reviewed_head`, head);
|
|
155
|
+
if (run.validator) {
|
|
156
|
+
assertCommit(repository, run.validator.reviewed_head, "validator reviewed_head", head);
|
|
157
|
+
const review = safeReview(sourceRunDir, "reviews/implementation-validator.json");
|
|
158
|
+
if (review.subject !== "implementation-validator" || review.reviewed_commit !== head || review.verdict !== run.validator.verdict) throw new RestoreError("validator review does not bind the restored verdict and feature head");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function restoredWorktree(recorded, runId) {
|
|
163
|
+
if (!isAbsolute(recorded)) return recorded;
|
|
164
|
+
const marker = `${sep}.factory-sandboxes${sep}${runId}`, at = resolve(recorded).lastIndexOf(marker);
|
|
165
|
+
if (at < 0) throw new RestoreError(`snapshot integration worktree '${recorded}' does not identify run '${runId}'`);
|
|
166
|
+
const suffix = resolve(recorded).slice(at + marker.length);
|
|
167
|
+
if (suffix !== "" && !suffix.startsWith(sep)) throw new RestoreError(`snapshot integration worktree '${recorded}' cannot be rebound`);
|
|
168
|
+
return suffix === "" ? "." : suffix.slice(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function qualifySource(operatorRoot, runId) {
|
|
172
|
+
const factory = join(operatorRoot, CONTROL_PLANE), parked = join(factory, ".parked"), source = join(parked, runId);
|
|
173
|
+
for (const [path, description] of [[factory, "operator control plane"], [parked, "park snapshot container"], [source, "park snapshot"]]) exactDirectory(path, description);
|
|
174
|
+
const manifest = join(source, "run.json"), stat = entryState(manifest);
|
|
175
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) throw new RestoreError(`park snapshot manifest '${manifest}' is not a regular file`);
|
|
176
|
+
const bytes = readFileSync(manifest), run = validateRun(JSON.parse(bytes.toString("utf8")));
|
|
177
|
+
if (run.run_id !== runId) throw new RestoreError(`park snapshot run_id '${run.run_id}' does not match requested '${runId}'`);
|
|
178
|
+
if (run.status !== "needs-human") throw new RestoreError(`factory restore requires a needs-human snapshot; found '${run.status}'`);
|
|
179
|
+
const entries = inventoryEntries(source), skipped = [...SKIPPED_ENTRIES];
|
|
180
|
+
return { source, bytes, run, inventory: JSON.stringify(entries),
|
|
181
|
+
copied: JSON.stringify(entries.filter((entry) => !skipped.some((name) => entry.startsWith(`${name} `) || entry.startsWith(`${name}/`)))) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateFeatureRef(repository, ref, branch) {
|
|
185
|
+
const listed = git(repository, ["remote"]);
|
|
186
|
+
const remotes = listed.status === 0 ? listed.stdout.split("\n").filter((name) => ref === `refs/remotes/${name}/${branch}`) : [];
|
|
187
|
+
const remote = remotes.length === 1 ? remotes[0] : null;
|
|
188
|
+
if (!remote) throw new RestoreError(`--from must name exact branch '${branch}' under one configured remote`);
|
|
189
|
+
const state = git(repository, ["show-ref", "--verify", "--quiet", ref]);
|
|
190
|
+
if (state.status !== 0) throw new RestoreError(`restore feature ref '${ref}' is absent or unobservable in operator repository '${repository}'`);
|
|
191
|
+
const head = exactOid(repository, ref);
|
|
192
|
+
if (!head) throw new RestoreError(`restore feature ref '${ref}' does not peel to one commit`);
|
|
193
|
+
const sourcePush = capturePushTarget(repository, remote), effectivePush = capturePushTarget(repository);
|
|
194
|
+
if (!sourcePush || !effectivePush || !sourcePush.equals(effectivePush) || !Buffer.from(sourcePush.toString("utf8"), "utf8").equals(sourcePush)) throw new RestoreError(`restore remote '${remote}' is not the operator's effective push endpoint`);
|
|
195
|
+
const endpoint = sourcePush.toString("utf8");
|
|
196
|
+
const remoteRef = `refs/heads/${branch}`, advertised = git(repository, ["ls-remote", "--exit-code", "--refs", endpoint, remoteRef]);
|
|
197
|
+
if (advertised.status !== 0 || advertised.stdout !== `${head}\t${remoteRef}\n`) throw new RestoreError(`restore feature ref '${ref}' does not match branch '${branch}' as advertised by remote '${remote}'`);
|
|
198
|
+
return head;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// False-green enforcement: local excludes do not survive the clone, so only the tracked root policy may
|
|
202
|
+
// prove that restored control-plane state stays outside the repository diff and cleanliness checks.
|
|
203
|
+
function assertIgnored(repository, runId, probes = [`.factory-sandboxes/${runId}/.factory/${runId}/run.json`, `${CONTROL_PLANE}/${runId}/run.json`]) {
|
|
204
|
+
const rootIgnore = join(repository, ".gitignore");
|
|
205
|
+
const tracked = git(repository, ["ls-files", "--error-unmatch", "--", ".gitignore"]);
|
|
206
|
+
for (const probe of probes) {
|
|
207
|
+
const observed = git(repository, ["check-ignore", "-v", "--no-index", "--", probe]);
|
|
208
|
+
const match = /^(.+):([1-9][0-9]*):(.+)\t(.+)\n$/u.exec(observed.status === 0 ? observed.stdout : "");
|
|
209
|
+
if (tracked.status !== 0 || tracked.stdout !== ".gitignore\n" || match?.[3].startsWith("!") || match?.[4] !== probe || resolve(repository, match?.[1] ?? "") !== rootIgnore) {
|
|
210
|
+
throw new RestoreError(`factory restore requires '${probe}' to be ignored by tracked root '.gitignore'`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertRestoreBinding({ operatorRoot, sandbox, runId, branch, worktree, featureRef, head, source }) {
|
|
216
|
+
for (const [path, description] of [[join(operatorRoot, CONTROL_PLANE), "operator control plane"], [join(operatorRoot, CONTROL_PLANE, ".parked"), "park snapshot container"], [source, "park snapshot"], [join(operatorRoot, ".factory-sandboxes"), "sandbox container"], [sandbox, "restore sandbox"]]) exactDirectory(path, description);
|
|
217
|
+
if (validateFeatureRef(operatorRoot, featureRef, branch) !== head) throw new RestoreError(`restore feature ref '${featureRef}' moved while restore was running`);
|
|
218
|
+
const symbolic = git(sandbox, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
219
|
+
if (symbolic.status !== 0 || symbolic.stdout !== `${branch}\n` || exactOid(sandbox, "HEAD") !== head) throw new RestoreError(`restored feature branch '${branch}' moved while restore was running`);
|
|
220
|
+
const cleanliness = git(sandbox, ["status", "--porcelain", "--untracked-files=normal"]);
|
|
221
|
+
if (cleanliness.status !== 0 || cleanliness.stdout !== "") throw new RestoreError("restored feature worktree changed while restore was running");
|
|
222
|
+
enforceEffectivePushTarget(["check", operatorRoot, sandbox]);
|
|
223
|
+
assertIgnored(operatorRoot, runId);
|
|
224
|
+
assertIgnored(sandbox, runId, [`${CONTROL_PLANE}/${runId}/run.json`]);
|
|
225
|
+
proveInitContainment({ operatorRoot, sandboxPath: sandbox, runId, worktree });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function dispatchRestore(positional, flags, operations = {}) {
|
|
229
|
+
if (positional.length !== 1 || !ID.test(positional[0])) throw new RestoreError("factory restore requires exactly one valid <run-id>");
|
|
230
|
+
if (flags.repo !== undefined && (typeof flags.repo !== "string" || !flags.repo.trim())) throw new RestoreError("--repo must be a non-empty string");
|
|
231
|
+
if (!flags.from) throw new RestoreError("factory restore requires --from <full-remote-tracking-ref>");
|
|
232
|
+
const runId = positional[0], operatorInput = resolve(flags.repo ?? process.cwd()), operatorRoot = realpathSync(operatorInput);
|
|
233
|
+
exactDirectory(operatorRoot, "operator repository");
|
|
234
|
+
const top = git(operatorRoot, ["rev-parse", "--show-toplevel"]);
|
|
235
|
+
if (!top.ok || resolve(operatorRoot, top.stdout.trim()) !== operatorRoot) throw new RestoreError("--repo must name the canonical operator repository root");
|
|
236
|
+
const qualified = qualifySource(operatorRoot, runId);
|
|
237
|
+
const worktree = restoredWorktree(qualified.run.worktree, runId);
|
|
238
|
+
const atMs = flags.now === undefined ? Date.now() : Date.parse(flags.now), at = Number.isFinite(atMs) ? new Date(atMs).toISOString() : null;
|
|
239
|
+
if (!at) throw new RestoreError("--now must be an ISO timestamp");
|
|
240
|
+
if (atMs <= Date.parse(qualified.run.updated_at)) throw new RestoreError("restore must move updated_at forwards");
|
|
241
|
+
if (git(operatorRoot, ["check-ref-format", "--branch", qualified.run.branch]).status !== 0) throw new RestoreError(`snapshot branch '${qualified.run.branch}' is not a valid branch name`);
|
|
242
|
+
const head = validateFeatureRef(operatorRoot, flags.from, qualified.run.branch);
|
|
243
|
+
assertIgnored(operatorRoot, runId);
|
|
244
|
+
const legacyManifest = join(operatorRoot, CONTROL_PLANE, runId, "run.json");
|
|
245
|
+
if (entryState(legacyManifest)) throw new RestoreError(`live run manifest already exists at '${legacyManifest}'`);
|
|
246
|
+
const container = join(operatorRoot, ".factory-sandboxes"), sandbox = join(container, runId);
|
|
247
|
+
const containerState = entryState(container);
|
|
248
|
+
if (containerState) exactDirectory(container, "sandbox container"); else mkdirSync(container);
|
|
249
|
+
if (entryState(sandbox)) throw new RestoreError(`restore sandbox destination '${sandbox}' already exists`);
|
|
250
|
+
mkdirSync(sandbox);
|
|
251
|
+
const cloned = git(operatorRoot, ["clone", "--local", "--no-checkout", "--", operatorRoot, sandbox]);
|
|
252
|
+
if (!cloned.ok) throw new RestoreError(`git clone failed for restore sandbox '${sandbox}'; sandbox was retained; run.json is absent`);
|
|
253
|
+
const switched = git(sandbox, ["switch", "--no-track", "-C", qualified.run.branch, head]);
|
|
254
|
+
if (!switched.ok) throw new RestoreError(`could not restore feature branch '${qualified.run.branch}' at '${head}'; sandbox was retained; run.json is absent`);
|
|
255
|
+
enforceEffectivePushTarget(["bootstrap", operatorRoot, sandbox]);
|
|
256
|
+
proveInitContainment({ operatorRoot, sandboxPath: sandbox, runId, worktree });
|
|
257
|
+
const runDir = join(sandbox, CONTROL_PLANE, runId), transformed = transformRun(qualified.run, at, head, worktree, qualified.source);
|
|
258
|
+
copySnapshot(qualified.source, runDir);
|
|
259
|
+
const copiedInventory = inventory(runDir);
|
|
260
|
+
if (copiedInventory !== qualified.copied) throw new RestoreError(`restored control-plane copy does not match the qualified snapshot; sandbox was retained; run.json is absent`);
|
|
261
|
+
assertPlanBinding(runDir, transformed.run);
|
|
262
|
+
assertPreservedBindings(runDir, transformed.run, sandbox, head);
|
|
263
|
+
const previousRestore = entryState(join(runDir, RESTORE_REF)) ? readRestoreRecord(runDir, qualified.run) : null;
|
|
264
|
+
const record = { version: 1, run_id: runId, restored_at: at, source_snapshot: qualified.source, source_inventory: `sha256:${createHash("sha256").update(qualified.inventory).digest("hex")}`, feature_ref: flags.from, feature_commit: head, reset_slices: transformed.resetSlices, invalidated: transformed.invalidated, previous_restore: previousRestore };
|
|
265
|
+
await writeProtectedJsonAtomic(runDir, RESTORE_REF, record);
|
|
266
|
+
const preparedInventory = inventory(runDir);
|
|
267
|
+
// Test seam only: production supplies no hook. The final guard below must catch any intervening writer.
|
|
268
|
+
if (operations.beforeManifest) await operations.beforeManifest({ runDir, sandbox });
|
|
269
|
+
const finalGuard = () => {
|
|
270
|
+
if (!readFileSync(join(qualified.source, "run.json")).equals(qualified.bytes) || inventory(qualified.source) !== qualified.inventory) throw new RestoreError("park snapshot changed while restore was running; run.json was not published");
|
|
271
|
+
if (inventory(runDir) !== preparedInventory) throw new RestoreError("restored control plane changed before manifest publication; run.json was not published");
|
|
272
|
+
if (entryState(legacyManifest)) throw new RestoreError(`live run manifest appeared at '${legacyManifest}' while restore was running`);
|
|
273
|
+
assertRestoreBinding({ operatorRoot, sandbox, runId, branch: qualified.run.branch, worktree, featureRef: flags.from, head, source: qualified.source });
|
|
274
|
+
};
|
|
275
|
+
await writeProtectedJsonAtomic(runDir, "run.json", transformed.run, { createOnly: true, hooks: { beforeCommit: finalGuard } });
|
|
276
|
+
const payload = { run_id: runId, status: transformed.run.status, sandbox_path: sandbox, run_dir: runDir, source_snapshot: qualified.source, feature_ref: flags.from, feature_commit: head, reset_slices: transformed.resetSlices, invalidated: transformed.invalidated, restore_record: join(runDir, RESTORE_REF) };
|
|
277
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else for (const [key, value] of Object.entries(payload)) process.stdout.write(`${key}: ${typeof value === "object" ? JSON.stringify(value) : value}\n`);
|
|
278
|
+
return payload;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const RESTORE_KEYS = ["version", "run_id", "restored_at", "source_snapshot", "source_inventory", "feature_ref", "feature_commit", "reset_slices", "invalidated", "previous_restore"];
|
|
282
|
+
const INVALIDATIONS = new Set(["gate:pre_pr", "validator", "step:test-verifier"]);
|
|
283
|
+
function validRestoreRecord(record, runId, slices) {
|
|
284
|
+
const timestamp = typeof record?.restored_at === "string" ? Date.parse(record.restored_at) : NaN;
|
|
285
|
+
return record && !Array.isArray(record) && JSON.stringify(Object.keys(record).sort()) === JSON.stringify([...RESTORE_KEYS].sort())
|
|
286
|
+
&& record.version === 1 && record.run_id === runId && ID.test(runId) && Number.isFinite(timestamp) && new Date(timestamp).toISOString() === record.restored_at
|
|
287
|
+
&& typeof record.source_snapshot === "string" && isAbsolute(record.source_snapshot) && /^sha256:[0-9a-f]{64}$/u.test(record.source_inventory)
|
|
288
|
+
&& /^refs\/remotes\/.+\/.+$/u.test(record.feature_ref) && SHA.test(record.feature_commit)
|
|
289
|
+
&& Array.isArray(record.reset_slices) && new Set(record.reset_slices).size === record.reset_slices.length && record.reset_slices.every((id) => ID.test(id) && (!slices || slices.has(id)))
|
|
290
|
+
&& Array.isArray(record.invalidated) && new Set(record.invalidated).size === record.invalidated.length && record.invalidated.every((item) => INVALIDATIONS.has(item))
|
|
291
|
+
&& (record.previous_restore === null || validRestoreRecord(record.previous_restore, runId, slices));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function readRestoreRecord(runDir, run = null) {
|
|
295
|
+
const path = join(runDir, RESTORE_REF), stat = entryState(path);
|
|
296
|
+
if (!stat) return null;
|
|
297
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new RestoreError(`restore record '${path}' is not a regular file`);
|
|
298
|
+
const record = jsonAt(runDir, RESTORE_REF, "restore record");
|
|
299
|
+
if (!validRestoreRecord(record, basename(runDir), run && new Set(run.slices.map((slice) => slice.id)))) throw new RestoreError(`restore record '${path}' is malformed`);
|
|
300
|
+
return record;
|
|
301
|
+
}
|
package/core/atomic-write.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Ordinary writes use an exclusive same-directory temp; every write fsyncs its file and directory. Directory fsync is
|
|
2
2
|
// what makes a completed publication survive power loss, and attack 9 (crash-recovery replay) rests
|
|
3
3
|
// on it. Ordinary writes recheck the target immediately before the rename, not only up front: this
|
|
4
4
|
// writes into a working tree, so a local process swapping run.json for a symlink inside that window
|
|
5
5
|
// is a real failure mode and an up-front-only check is a TOCTOU hole. Create-only writes preflight
|
|
6
|
-
// absence
|
|
7
|
-
|
|
6
|
+
// absence, then write through the protected target's held mode-000 inode; only verified, fsynced bytes
|
|
7
|
+
// become readable. beforeCommit is the last race seam used by CAS and create-only tests.
|
|
8
|
+
import { lstat, open, realpath, rename as fsRename, unlink } from "node:fs/promises";
|
|
8
9
|
import { randomUUID } from "node:crypto";
|
|
9
10
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
10
11
|
|
|
@@ -20,72 +21,83 @@ export function writeProtectedJsonAtomic(rootDir, relativePath, value, options =
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export async function writeProtectedFileAtomic(rootDir, relativePath, data, options = {}) {
|
|
23
|
-
const targetPath = resolveProtectedPath(rootDir, relativePath);
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const beforeCommit = options.hooks?.beforeCommit;
|
|
29
|
-
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8");
|
|
30
|
-
|
|
24
|
+
const targetPath = resolveProtectedPath(rootDir, relativePath), parentDir = resolve(targetPath, "..");
|
|
25
|
+
const createOnly = options.createOnly === true, beforeCommit = options.hooks?.beforeCommit;
|
|
26
|
+
const openFile = options.fsOps?.open ?? open;
|
|
27
|
+
const bytes = Buffer.isBuffer(data) ? Buffer.from(data) : Buffer.from(String(data), "utf8");
|
|
28
|
+
await assertSafeParent(rootDir, parentDir);
|
|
31
29
|
await assertSafeTarget(targetPath, createOnly);
|
|
30
|
+
if (createOnly) return writeProtectedCreate(rootDir, parentDir, targetPath, bytes, beforeCommit, openFile);
|
|
32
31
|
|
|
33
|
-
const tempPath = join(parentDir, `.${randomUUID()}.tmp`);
|
|
34
|
-
let handle = null;
|
|
35
|
-
let published = false;
|
|
32
|
+
const tempPath = join(parentDir, `.${randomUUID()}.tmp`), rename = options.fsOps?.rename ?? fsRename;
|
|
33
|
+
let handle = null, published = false, targetLinked = false;
|
|
36
34
|
try {
|
|
37
|
-
|
|
38
|
-
// a file somebody else created.
|
|
39
|
-
handle = await open(tempPath, "wx", 0o600);
|
|
35
|
+
handle = await openFile(tempPath, "wx+", 0o600);
|
|
40
36
|
await handle.writeFile(bytes);
|
|
41
37
|
await handle.sync();
|
|
38
|
+
if (typeof beforeCommit === "function") await beforeCommit();
|
|
39
|
+
await assertSafeParent(rootDir, parentDir);
|
|
40
|
+
await assertSafeTarget(targetPath, false);
|
|
41
|
+
await assertPublishedInode(handle, tempPath, bytes);
|
|
42
|
+
await rename(tempPath, targetPath);
|
|
43
|
+
targetLinked = true;
|
|
44
|
+
await assertPublishedInode(handle, targetPath, bytes);
|
|
42
45
|
await handle.close();
|
|
43
46
|
handle = null;
|
|
44
|
-
|
|
45
|
-
if (createOnly) {
|
|
46
|
-
await assertSafeTarget(targetPath, true);
|
|
47
|
-
if (typeof beforeCommit === "function") await beforeCommit();
|
|
48
|
-
try { await link(tempPath, targetPath); } catch (error) {
|
|
49
|
-
if (error?.code === "EEXIST") throw new ProtectedWriteError("protected create target already exists", error);
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
published = true;
|
|
53
|
-
try { await unlink(tempPath); } catch (cleanupError) {
|
|
54
|
-
try { await unlink(tempPath); } catch (retryError) {
|
|
55
|
-
if (retryError?.code !== "ENOENT") {
|
|
56
|
-
throw new ProtectedWriteError("protected create published target but temporary cleanup is indeterminate", retryError);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
throw new ProtectedWriteError("protected create published target but initial temporary cleanup failed", cleanupError);
|
|
60
|
-
}
|
|
61
|
-
} else {
|
|
62
|
-
if (typeof beforeCommit === "function") await beforeCommit();
|
|
63
|
-
await assertSafeTarget(targetPath, false);
|
|
64
|
-
await rename(tempPath, targetPath);
|
|
65
|
-
published = true;
|
|
66
|
-
}
|
|
47
|
+
published = true;
|
|
67
48
|
} catch (error) {
|
|
68
|
-
if (handle) {
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
await unlink(tempPath);
|
|
74
|
-
} catch (cleanupError) {
|
|
75
|
-
if (cleanupError?.code !== "ENOENT") {
|
|
76
|
-
throw new ProtectedWriteError("protected temporary file cleanup is indeterminate", cleanupError);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
49
|
+
if (handle) try { await handle.close(); } catch { /* the original error is primary */ }
|
|
50
|
+
if (targetLinked && !published) try { await unlink(targetPath); } catch { /* integrity failure is primary */ }
|
|
51
|
+
if (!published) try { await unlink(tempPath); } catch (cleanupError) {
|
|
52
|
+
if (cleanupError?.code !== "ENOENT") throw new ProtectedWriteError("protected temporary file cleanup is indeterminate", cleanupError);
|
|
79
53
|
}
|
|
80
|
-
throw error instanceof ProtectedWriteError
|
|
81
|
-
? error
|
|
82
|
-
: new ProtectedWriteError("protected file commit failed", error);
|
|
54
|
+
throw error instanceof ProtectedWriteError ? error : new ProtectedWriteError("protected file commit failed", error);
|
|
83
55
|
}
|
|
56
|
+
await syncDirectory(parentDir);
|
|
57
|
+
return { path: targetPath };
|
|
58
|
+
}
|
|
84
59
|
|
|
60
|
+
async function writeProtectedCreate(rootDir, parentDir, targetPath, bytes, beforeCommit, openFile) {
|
|
61
|
+
let handle = null;
|
|
62
|
+
try {
|
|
63
|
+
if (typeof beforeCommit === "function") await beforeCommit();
|
|
64
|
+
await assertSafeParent(rootDir, parentDir);
|
|
65
|
+
await assertSafeTarget(targetPath, true);
|
|
66
|
+
try { handle = await openFile(targetPath, "wx+", 0o000); } catch (error) {
|
|
67
|
+
if (error?.code === "EEXIST") throw new ProtectedWriteError("protected create target already exists", error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
await handle.writeFile(bytes);
|
|
71
|
+
await handle.sync();
|
|
72
|
+
await assertPublishedInode(handle, targetPath, bytes);
|
|
73
|
+
await handle.chmod(0o600);
|
|
74
|
+
await handle.sync();
|
|
75
|
+
await assertPublishedInode(handle, targetPath, bytes);
|
|
76
|
+
await handle.close();
|
|
77
|
+
handle = null;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (handle) try { await handle.close(); } catch { /* the original error is primary */ }
|
|
80
|
+
// Never unlink by pathname after claiming it: a competitor could have replaced that name.
|
|
81
|
+
// A failed direct create remains mode 000 and blocks reuse instead of exposing uncertain state.
|
|
82
|
+
throw error instanceof ProtectedWriteError ? error : new ProtectedWriteError("protected file commit failed", error);
|
|
83
|
+
}
|
|
85
84
|
await syncDirectory(parentDir);
|
|
86
85
|
return { path: targetPath };
|
|
87
86
|
}
|
|
88
87
|
|
|
88
|
+
async function assertPublishedInode(handle, targetPath, bytes) {
|
|
89
|
+
const [held, named] = await Promise.all([handle.stat(), lstat(targetPath)]);
|
|
90
|
+
const observed = Buffer.alloc(bytes.length + 1), read = await handle.read(observed, 0, observed.length, 0);
|
|
91
|
+
if (!named.isFile() || held.dev !== named.dev || held.ino !== named.ino || read.bytesRead !== bytes.length
|
|
92
|
+
|| !observed.subarray(0, bytes.length).equals(bytes)) throw new ProtectedWriteError("protected publication inode or bytes changed before commit");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function assertSafeParent(rootDir, parentDir) {
|
|
96
|
+
const root = resolve(rootDir), rel = resolve(parentDir).slice(root.length + 1);
|
|
97
|
+
let observed; try { observed = await realpath(parentDir); } catch (error) { throw new ProtectedWriteError("protected file parent could not be inspected", error); }
|
|
98
|
+
if (observed !== resolve(await realpath(root), rel)) throw new ProtectedWriteError("protected file parent has an unsafe symlink");
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
function resolveProtectedPath(rootDir, relativePath) {
|
|
90
102
|
if (typeof rootDir !== "string" || !rootDir.trim() || !isAbsolute(rootDir)) {
|
|
91
103
|
throw new ProtectedWriteError("protected file root is invalid");
|
package/core/effective-push.js
CHANGED
|
@@ -20,10 +20,10 @@ function execute(run, args) {
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
23
|
+
export function capturePushTarget(repository, remote = "origin", run = spawnSync) {
|
|
24
24
|
let result;
|
|
25
25
|
try {
|
|
26
|
-
result = execute(run, ["-C", repository, "remote", "get-url", "--push",
|
|
26
|
+
result = execute(run, ["-C", repository, "remote", "get-url", "--push", remote]);
|
|
27
27
|
} catch {
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
@@ -62,7 +62,7 @@ export function enforceEffectivePushTarget(positionals, { spawnSync: run = spawn
|
|
|
62
62
|
const [operation, operatorRepository, sandboxRepository] = positionals;
|
|
63
63
|
if (operation !== "bootstrap" && operation !== "check") throw failure(OPERATION_ERROR);
|
|
64
64
|
|
|
65
|
-
let operatorTarget =
|
|
65
|
+
let operatorTarget = capturePushTarget(operatorRepository, "origin", run);
|
|
66
66
|
if (operatorTarget === null) {
|
|
67
67
|
throw failure(`factory sandbox: operator effective push target unavailable; sandbox retained at ${sandboxRepository}`);
|
|
68
68
|
}
|
|
@@ -73,12 +73,12 @@ export function enforceEffectivePushTarget(positionals, { spawnSync: run = spawn
|
|
|
73
73
|
if (!configure(run, sandboxRepository, operatorTarget.toString("utf8"))) {
|
|
74
74
|
throw failure(`factory sandbox: sandbox effective push target unavailable at ${sandboxRepository}`);
|
|
75
75
|
}
|
|
76
|
-
operatorTarget =
|
|
76
|
+
operatorTarget = capturePushTarget(operatorRepository, "origin", run);
|
|
77
77
|
if (operatorTarget === null) {
|
|
78
78
|
throw failure(`factory sandbox: operator effective push target unavailable; sandbox retained at ${sandboxRepository}`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
const sandboxTarget =
|
|
81
|
+
const sandboxTarget = capturePushTarget(sandboxRepository, "origin", run);
|
|
82
82
|
if (sandboxTarget === null) {
|
|
83
83
|
throw failure(`factory sandbox: sandbox effective push target unavailable at ${sandboxRepository}`);
|
|
84
84
|
}
|
|
@@ -16,8 +16,12 @@ export function parseRepositoryConfig(bytes) {
|
|
|
16
16
|
// resolved by `init` from a flag or the environment and recorded in `run.json`. The allowed set below is
|
|
17
17
|
// closed, so a file still carrying the key is malformed rather than silently ignored -- which is what
|
|
18
18
|
// makes the removal visible to whoever has to edit it.
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// `publish` was required from #308 and invoked nowhere, so every consumer wrote a command that could
|
|
20
|
+
// not run -- and a reader who saw it reasonably concluded the factory owned publication, which it does
|
|
21
|
+
// not. It is optional now and consumed when present, so the key means what it says either way.
|
|
22
|
+
const requiredKeys = ["resolve", "verify"];
|
|
23
|
+
const optionalCommandKeys = ["publish"];
|
|
24
|
+
const allowedKeys = [...requiredKeys, ...optionalCommandKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
|
|
21
25
|
if (!config || typeof config !== "object" || Array.isArray(config)
|
|
22
26
|
|| Object.keys(config).some((keyName) => !allowedKeys.includes(keyName))) {
|
|
23
27
|
throw new RepositoryConfigError("invalid .factory.json");
|
|
@@ -43,6 +47,12 @@ export function parseRepositoryConfig(bytes) {
|
|
|
43
47
|
if (requiredKeys.some((keyName) => typeof config[keyName] !== "string" || !config[keyName].trim())) {
|
|
44
48
|
throw new RepositoryConfigError("invalid .factory.json");
|
|
45
49
|
}
|
|
50
|
+
// Optional does not mean unchecked: an empty or non-string `publish` is a declared command that cannot
|
|
51
|
+
// run, which is the state this key was already in and the one worth refusing loudly.
|
|
52
|
+
if (optionalCommandKeys.some((keyName) => Object.hasOwn(config, keyName)
|
|
53
|
+
&& (typeof config[keyName] !== "string" || !config[keyName].trim()))) {
|
|
54
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'publish' must be a non-empty string");
|
|
55
|
+
}
|
|
46
56
|
const parsed = { command: config.verify, timeoutMs: config.verify_timeout_ms ?? DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS,
|
|
47
57
|
prDraft: config.pr_draft ?? true };
|
|
48
58
|
return hasBootstrap ? { ...parsed, bootstrapCommand: config.bootstrap,
|