feature-factory 0.8.2 → 0.8.3

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
@@ -115,6 +115,8 @@ re-initialising the same run id. It is published by a staged, verified swap, so
115
115
  snapshot. A failed snapshot is reported and never prevents the park. `blocked`
116
116
  and `partial` are not snapshotted, and a snapshot is evidence for recovery rather than a resumable run.
117
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.
118
120
  Malformed config, malformed payload, a non-zero exit, or unavailable exit status refuses before any
119
121
  run effect and never falls back:
120
122
 
package/WORKFLOW.md CHANGED
@@ -148,12 +148,24 @@ resume; invocation flags do not select resumed behavior:
148
148
  An inability to ask a human never promotes interactive or headless to autonomous.
149
149
 
150
150
  Mode result needs-human means parked and explicitly resumable; only completed, partial, and blocked are final.
151
- Enter the parked stop with factory terminal R needs-human --reason TEXT; leave it only by explicit factory resume R --session $SESSION_ID --repo S, which refuses unless that session already holds a fresh lock: claim, then verify, then resume.
152
- Immediately after recording a `needs-human` terminalization, and before reporting the park to the operator,
153
- publish a parked control-plane snapshot exactly as *Parked control-plane snapshot* below requires. A park
154
- is not reported until that snapshot is published or its failure is recorded in the report.
151
+ **The parked stop is one ordered sequence, and every rule in this document that says a run parks enters
152
+ it.** Those rules name the cause; they do not restate the steps. Execute all three, in order, before
153
+ reporting anything:
154
+
155
+ 1. Enter the parked stop with factory terminal R needs-human --reason TEXT; leave it only by explicit factory resume R --session $SESSION_ID --repo S, which refuses unless that session already holds a fresh lock: claim, then verify, then resume.
156
+ 2. Immediately after recording a `needs-human` terminalization, and before reporting the park to the operator,
157
+ publish a parked control-plane snapshot exactly as *Parked control-plane snapshot* below requires. A park
158
+ is not reported until that snapshot is published or its failure is recorded in the report.
159
+ 3. Report top-level needs-human as parked with its reason and explicit factory resume command.
160
+
161
+ A park that completes only step 1 is an unreported park with no recovery evidence, which is the state
162
+ this sequence exists to prevent. Verify step 2 the way an outside observer would: qualified status
163
+ reports `park_snapshot` as the published path, or `null` when no snapshot exists. Status reports the path
164
+ only while the snapshot is a complete copy of the live plane by the step 3 inventory and its manifest still
165
+ matches the live one byte for byte, so `null` also covers an interrupted or altered copy and a snapshot
166
+ left by an earlier park: neither is evidence for this park. Publishing again is what makes it correspond.
167
+
155
168
  For top-level needs-human, status exposes the durable next action, but no command may execute it before explicit factory resume.
156
- Report top-level needs-human as parked with its reason and explicit factory resume command.
157
169
  Retain the sandbox for top-level needs-human while parked, then explicitly resume it after the external fix.
158
170
  A park that asks a question about the request itself -- a contradiction between criteria, a scope lock,
159
171
  or a pinned constraint -- is not fixed by resuming. Resume continues from the existing manifest and
package/bin/factory.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // The orchestrator calls this CLI instead of writing control-plane state directly.
5
5
  // Flags are declared per command; unknown options fail rather than becoming missing fields.
6
6
  // Schema validation surrounds every state write.
7
- import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
8
- import { join, resolve } from "node:path";
7
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { createHash } from "node:crypto";
11
11
  import { isDeepStrictEqual } from "node:util";
@@ -153,6 +153,57 @@ function briefDigestFor(decision, state, runDir) {
153
153
  return state.plan_digest;
154
154
  }
155
155
 
156
+ // Observability, not enforcement: the copy stays a driver step, because this CLI is forbidden copy and
157
+ // delete primitives. What this answers has to be exact, and two earlier versions were not. "Does the
158
+ // pathname exist" reported a snapshot from an earlier park as this park's evidence. Matching only `run.json`
159
+ // then proved one file was copied after the current terminalization, not that the publication finished --
160
+ // a driver that created the directory and copied that file first, or an interrupted copy, still read as
161
+ // published. Both were false greens in the exact case this exists to catch, and both were caught in review.
162
+ // So compare the inventory the publication contract already defines, in the same terms it defines it:
163
+ // `.` and every descendant, each recording relative path, type, permission mode, SHA-256 for a regular
164
+ // file and link target for a symlink, sorted lexically by relative path, with unsupported entry types
165
+ // rejected. Two earlier versions of this comparison were narrower than the contract they claimed to check.
166
+ // Recording sizes rather than digests -- on the theory that hashing was too costly for a continuously
167
+ // polled command -- passed a same-length byte change and a mode-only change as faithful. Then walking into
168
+ // the root without recording it passed a snapshot whose own directory mode differed from the plane's. Both
169
+ // reported a tree that fails the contract's verification as published, while the documentation said altered
170
+ // trees yield `null`, and a signal that disagrees with its own description is the thing this whole change
171
+ // exists to remove. Hashing the plane costs about a millisecond; the cost theory was right and its
172
+ // conclusion was wrong. Both caught in review.
173
+ // Every path component is checked with `lstat` and never followed, since `lstat` on the final entry alone
174
+ // still follows intermediate symlinks.
175
+ function planeInventory(root) {
176
+ const entries = [];
177
+ const record = (rel, full) => {
178
+ const stat = lstatSync(full);
179
+ const mode = (stat.mode & 0o7777).toString(8);
180
+ if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
181
+ else if (stat.isDirectory()) {
182
+ entries.push(`${rel} d ${mode}`);
183
+ for (const name of readdirSync(full)) record(rel === "." ? name : `${rel}/${name}`, join(full, name));
184
+ } else if (stat.isFile()) entries.push(`${rel} f ${mode} ${createHash("sha256").update(readFileSync(full)).digest("hex")}`);
185
+ else throw new CliError(`unsupported entry type in '${full}'`);
186
+ };
187
+ record(".", root);
188
+ return entries.sort().join("\n");
189
+ }
190
+
191
+ function observedParkSnapshot(repo, runId, runDir) {
192
+ const container = dirname(repo);
193
+ if (basename(container) !== ".factory-sandboxes" || basename(repo) !== runId) return null;
194
+ const operatorRoot = dirname(container);
195
+ const candidate = join(operatorRoot, CONTROL_PLANE, ".parked", runId);
196
+ try {
197
+ for (const component of [join(operatorRoot, CONTROL_PLANE), join(operatorRoot, CONTROL_PLANE, ".parked"), candidate]) {
198
+ if (!lstatSync(component).isDirectory()) return null;
199
+ }
200
+ if (planeInventory(candidate) !== planeInventory(runDir)) return null;
201
+ return readFileSync(join(candidate, "run.json")).equals(readFileSync(join(runDir, "run.json"))) ? candidate : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
156
207
  function runDirFor(flags, runId) {
157
208
  if (!runId) throw new CliError("a <run-id> is required");
158
209
  return join(resolve(flags.repo ?? process.cwd()), CONTROL_PLANE, runId);
@@ -903,6 +954,7 @@ const HANDLERS = {
903
954
  branch: run.branch,
904
955
  pr_base: run.pr_base ?? null,
905
956
  publishing_identity: run.publishing_identity ?? null,
957
+ park_snapshot: run.status === "needs-human" ? observedParkSnapshot(resolve(flags.repo ?? process.cwd()), runId, runDir) : null,
906
958
  pr_draft: run.pr_draft ?? true,
907
959
  lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
908
960
  lock_session: lock.owner?.session ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",