feature-factory 0.9.2 → 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 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 three required properties and four optional properties: `pr_draft`, `verify_timeout_ms`,
41
- `bootstrap`, and `bootstrap_timeout_ms`. `resolve`, `verify`, `publish`, and a present `bootstrap` are non-empty command
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`; then missing or invalid required entries.
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, and a snapshot is evidence for recovery rather than a resumable run.
117
-
118
- Qualified status reports `park_snapshot` for a parked run: the published path, or `null` when no snapshot
119
- exists. That is how an outside observer verifies the snapshot happened rather than assuming it.
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. Existing resolver, verify, configured-publish, effective-push, push, PR, and Gate 3 behavior is unchanged.
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. Configured `publish` remains unconsumed and is not invoked.
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 `gh pr create`, after the push is known
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
- `gh pr create`, `factory pr`, Gate 3, merge, and approval semantics remain unchanged. The live config
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`, adds no run state or flag, and configured `publish` remains unconsumed.
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 evidence for recovery, not a resumable run: resume operates on the sandbox manifest. Do not
308
- publish one for `blocked` or `partial`, which are not resumable, and never treat a snapshot as authority
309
- over the live plane.
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 three required own properties `resolve`, `verify`, and `publish`,
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. There is no `publishing_identity` key: the account a run publishes as is a
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`; then missing or invalid required entries.
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 unconsumed `publish` entries impossible and left this repository unable to resolve
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, or publication behavior.
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. Configured `publish` remains unconsumed and is not invoked.
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` | Future ordinary shell step in repository-root cwd with inherited environment; no structured stdin or factory-specific payload is defined | Exit status is authoritative; stdout is informational and unparsed | Zero means the command reported success; non-zero means it reported failure | Not invoked. Existing `git push`, `gh pr create`, and `factory pr` behavior remains unchanged; effective push-target equality is enforced separately by <code>factory effective-push</code>. |
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 never uses the pre-resume observation or the stop reason.
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
@@ -1757,16 +1894,27 @@ HEAD, a branch name, or an unpersisted variable.
1757
1894
  is not a slice and has no slice `test_plan`, so it continues to supply its integration command. There
1758
1895
  is no waiver: the stage exists to run the tests, so the evidence must record an observed run that
1759
1896
  exited zero, against the integration head as it stands. Then `work-reviewer` confirms each criterion
1760
- 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.
1761
1902
  This Gate 3 observation is always fresh and independent in the ordinary path. It uses the existing
1762
1903
  argv-tokenized `--test-cmd` path and overwrites canonical evidence at the current head. The sole
1763
1904
  substitution is a qualifying explicit repair re-verification pass at current HEAD under Gate 3's
1764
1905
  complete inventory rules below; failed ordinary evidence remains preserved rather than overwritten.
1765
1906
  2. `implementation-validator` — the holistic pass across the whole diff, complementing per-slice
1766
1907
  reviews. **Skip it when the run has exactly one slice**: its subject is the interaction *between*
1767
- slices, and with one there is none, so it re-reads the diff the slice reviewer just approved —
1768
- a serialized pass on the critical path for no new information. Gate 3 does not require a verdict
1769
- for a single-slice run. Run it for every multi-slice run; the gate refuses without it.
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.
1770
1918
 
1771
1919
  When you do run it, it returns GO / GO-WITH-NITS / NO-GO **and writes `reviews/implementation-validator.json`
1772
1920
  naming the commit it judged**, exactly like any other reviewer. Then:
@@ -1978,17 +2126,60 @@ gh api --method GET /user --jq .login
1978
2126
  factory pr "$R" --url "$PR_URL" --repo "$RUN_REPO"
1979
2127
  ```
1980
2128
 
1981
- The second observation runs only after that push is known successful and immediately before unchanged
1982
- `gh pr create`, with no intervening operation. Both Step 6 guards are skipped when `.factory.json` is
1983
- absent. A mismatch or unobservable result follows the common quiesce, park, durable-reason, owning
1984
- release, unlock-verification, retention, reporting, and later-driver procedure above. There is no
1985
- separate identity guard before `factory pr`; preserve that command and every existing publication mode,
1986
- status, and gate exactly.
1987
-
1988
- The `gh` call is the orchestrator's external effect; the package makes no forge call and `factory pr`
1989
- does not verify the forge's base. For a legacy manifest where `pr_base` is absent or null, stop and
1990
- require a human/operator to choose or confirm the exact target, then pass that value through
1991
- `gh pr create --base`. Never infer it from HEAD, the feature branch, repository or forge defaults, and
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
1992
2183
  never backfill the legacy manifest.
1993
2184
 
1994
2185
  `pr_url` is immutable once recorded — a run has one PR, and overwriting the URL would hide a second
@@ -2159,6 +2350,12 @@ accepted NO-GO findings, recorded overrides, retained or residual sandboxes, or
2159
2350
 
2160
2351
  ## Resuming
2161
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
+
2162
2359
  On invocation, if the run directory exists and you hold or steal the lock, the preserved compatibility
2163
2360
  claim reads “run `factory status <run-id> --json` and resume; never restart.” It names a non-runnable
2164
2361
  command stem. Execute only `factory status "$R" --json --repo "$RUN_REPO"`, then continue from `next`:
@@ -121,7 +121,7 @@ When the subject is a **class-wide** requirement — one that **cannot be establ
121
121
  docs-only slice reviewed against tests it was ratified not to have is rejected forever; that
122
122
  exemption is a plan decision, not yours to re-open here. It waives **test execution only**: the
123
123
  acceptance must still be implemented and the diff must still be observed.
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.
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.
125
125
 
126
126
  ## Security proportionality
127
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
+ }
@@ -1,10 +1,11 @@
1
- // Exclusive same-directory temp, then fsync of the file and of the directory. The directory fsync is
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 and publish by link. beforeCommit is the last race seam, used by CAS and create-only tests.
7
- import { link as fsLink, lstat, open, rename as fsRename, unlink } from "node:fs/promises";
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 parentDir = resolve(targetPath, "..");
25
- const createOnly = options.createOnly === true;
26
- const rename = options.fsOps?.rename ?? fsRename;
27
- const link = options.fsOps?.link ?? fsLink;
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
- // "wx" is O_CREAT|O_EXCL|O_WRONLY: if the name exists, fail rather than adopt
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
- try { await handle.close(); } catch { /* the original error is the one that matters */ }
70
- }
71
- if (!published) {
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");
@@ -20,10 +20,10 @@ function execute(run, args) {
20
20
  });
21
21
  }
22
22
 
23
- function capture(run, repository) {
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", "origin"]);
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 = capture(run, operatorRepository);
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 = capture(run, operatorRepository);
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 = capture(run, sandboxRepository);
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
- const requiredKeys = ["publish", "resolve", "verify"];
20
- const allowedKeys = [...requiredKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",