feature-factory 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/WORKFLOW.md CHANGED
@@ -177,7 +177,8 @@ reporting anything:
177
177
  A park that completes only step 1 is an unreported park with no recovery evidence, which is the state
178
178
  this sequence exists to prevent. Verify step 2 the way an outside observer would: qualified status
179
179
  reports `park_snapshot` as the published path, or `null` when no snapshot exists. Status reports the path
180
- only while the snapshot is a complete copy of the live plane by the step 3 inventory and its manifest still
180
+ only while the snapshot is a complete copy of the live plane by the step 3 inventory — `factory.lock`
181
+ excluded, since a heartbeat may land between the copy and the read — and its manifest still
181
182
  matches the live one byte for byte, so `null` also covers an interrupted or altered copy and a snapshot
182
183
  left by an earlier park: neither is evidence for this park. Publishing again is what makes it correspond.
183
184
 
@@ -243,7 +244,12 @@ and "clean up the prior copy" are contradictory instructions once that rename ha
243
244
  outside `P`; do not copy slice worktrees or any other part of `S`.
244
245
  3. **Verify.** Build source and destination inventories exactly as the completed archive does — every
245
246
  entry's relative path, type and mode, a SHA-256 for each regular file, a link target for each symlink,
246
- sorted lexically — and require exact equality. An unverified staging tree is never published.
247
+ sorted lexically — and require exact equality, **excluding the plane-root `factory.lock` only**. That
248
+ one entry is session liveness rather than run state and is the only thing in the plane designed to
249
+ change on a timer, so comparing it fails whenever a heartbeat lands between reading the source and
250
+ reading the copy. The exclusion is that exact path and nothing else: a `factory.lock` anywhere below
251
+ the plane root is run state and must match. Qualified status excludes the same single path for the same
252
+ reason. An unverified staging tree is never published.
247
253
  4. **Commit.** With no snapshot at the canonical path, rename `.staging-$R` onto it; that rename is the
248
254
  commit point. With one present, first rename the canonical snapshot to `.prior-$R`, then rename
249
255
  `.staging-$R` onto the canonical path; that second rename is the commit point. If the first rename
package/bin/factory.js CHANGED
@@ -10,7 +10,7 @@ import { pathToFileURL } from "node:url";
10
10
  import { createHash } from "node:crypto";
11
11
  import { isDeepStrictEqual } from "node:util";
12
12
  import { readFileSync } from "node:fs";
13
- import { nextAction, readRun, readRunUnchecked } from "../state/index.js";
13
+ import { nextAction, nextActionRecord, readRun, readRunUnchecked } from "../state/index.js";
14
14
  import { transition } from "../state/transition.js";
15
15
  import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
16
16
  import { assertPublicationReady, assertReviewBinding, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
@@ -23,7 +23,7 @@ import { resolveSpawnExecutable } from "../core/executable.js";
23
23
  import { dispatchInitPublication } from "./init-publication.js";
24
24
  import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, repositoryRelativePath, validateRun } from "../state/schema.js";
25
25
  import {
26
- claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SessionLockHeldError,
26
+ claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SESSION_LOCK_FILE, SessionLockHeldError,
27
27
  } from "../state/session-lock.js";
28
28
 
29
29
  export const COMMANDS = Object.freeze({
@@ -175,6 +175,15 @@ function briefDigestFor(decision, state, runDir) {
175
175
  function planeInventory(root) {
176
176
  const entries = [];
177
177
  const record = (rel, full) => {
178
+ // The session lock is liveness, not run state, and it is the one entry in the plane designed to change
179
+ // on a timer. Comparing it made every snapshot invalid within one heartbeat: a live park published a
180
+ // complete, byte-correct plane and `status` reported `park_snapshot: null` eleven seconds later,
181
+ // because the copy held `heartbeat_at` 23:28:25 and the plane had moved to 23:28:36. The whole point
182
+ // of the field was to answer "did the driver publish it", and it answered "no" for a snapshot that was
183
+ // there -- the same disagree-with-your-own-description defect the 0.8.3 work existed to remove,
184
+ // reintroduced by the check built to remove it. Every existing test publishes and reads back with no
185
+ // heartbeat in between, which is why three review rounds and a real park all missed it.
186
+ if (rel === SESSION_LOCK_FILE) return;
178
187
  const stat = lstatSync(full);
179
188
  const mode = (stat.mode & 0o7777).toString(8);
180
189
  if (stat.isSymbolicLink()) entries.push(`${rel} l ${mode} ${readlinkSync(full)}`);
@@ -963,12 +972,31 @@ const HANDLERS = {
963
972
  pr_draft: run.pr_draft ?? true,
964
973
  lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
965
974
  lock_session: lock.owner?.session ?? null,
966
- gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name]).map((name) => [name, run.gates[name].status])),
967
- steps: run.steps.map((step) => `${step.agent}:${step.status}(${step.attempts})`),
968
- slices: run.slices.map((slice) => `${slice.id}:${slice.status}(${slice.attempts})`),
969
- validator: run.validator?.verdict ?? null,
975
+ // The whole gate record, not just its status. `at` and `artifact` were dropped here while sitting
976
+ // intact in `run.json` -- and `at` is what tells a controller whether "approved" happened a minute
977
+ // ago or three hours ago, which is most of what "is this run stuck" means.
978
+ gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name])
979
+ .map((name) => [name, { status: run.gates[name].status, at: run.gates[name].at ?? null, artifact: run.gates[name].artifact ?? null }])),
980
+ // Structured, not `${agent}:${status}(${attempts})`. Attempts are the field a controller reads to
981
+ // decide whether an attempt was consumed, and reaching them meant regexing a display string out of
982
+ // a JSON contract. Nothing in the suite asserted the string form, so it was a public shape with no
983
+ // coverage -- which is how a rendering artifact survived in a machine-readable payload. The content
984
+ // is unchanged; only the shape is. Breaking for anyone parsing the strings, which is safe here
985
+ // because an exact-match `FACTORY_VERSION` pin already forces consumers to move deliberately.
986
+ steps: run.steps.map((step) => ({ agent: step.agent, status: step.status, attempts: step.attempts })),
987
+ slices: run.slices.map((slice) => ({ id: slice.id, status: slice.status, attempts: slice.attempts })),
988
+ // Same narrowing: the record carries `report`, `reviewed_head` and `loops`, and only the verdict
989
+ // came out. `loops` says whether validation is converging; `reviewed_head` says what it judged.
990
+ validator: run.validator
991
+ ? { verdict: run.validator.verdict, report: run.validator.report ?? null,
992
+ reviewed_head: run.validator.reviewed_head ?? null, loops: run.validator.loops ?? null }
993
+ : null,
970
994
  pr_url: run.pr_url,
971
995
  terminal_result: run.terminal_result,
996
+ // Both, deliberately. `next_action` is the machine answer; `next` is its rendering, derived from the
997
+ // same record by one formatter so they cannot drift, and kept because the driver contract, the
998
+ // sidebar and a lot of prose name `next: gate:story`.
999
+ next_action: nextActionRecord(run),
972
1000
  next: nextAction(run),
973
1001
  });
974
1002
  },
@@ -1163,7 +1191,8 @@ const HANDLERS = {
1163
1191
  });
1164
1192
  if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
1165
1193
  return emit(flags, {
1166
- run_id: runId, status: next.status, terminal_result: next.terminal_result, next: nextAction(next),
1194
+ run_id: runId, status: next.status, terminal_result: next.terminal_result,
1195
+ next_action: nextActionRecord(next), next: nextAction(next),
1167
1196
  });
1168
1197
  },
1169
1198
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/state/index.js CHANGED
@@ -28,11 +28,11 @@ export function readRunUnchecked(runDir) {
28
28
 
29
29
  function nextSliceAction(slices) {
30
30
  const blockedSlice = slices.find((slice) => slice.status === "blocked");
31
- if (blockedSlice) return `blocked-slice:${blockedSlice.id}`;
31
+ if (blockedSlice) return { kind: "blocked-slice", subject: blockedSlice.id };
32
32
  const activeSlice = slices.find((slice) => ["running", "review"].includes(slice.status));
33
- if (activeSlice) return `observe-slice:${activeSlice.id}`;
33
+ if (activeSlice) return { kind: "observe-slice", subject: activeSlice.id };
34
34
  const pendingSlice = slices.find((slice) => slice.status === "pending");
35
- return pendingSlice ? `dispatch-slice:${pendingSlice.id}` : undefined;
35
+ return pendingSlice ? { kind: "dispatch-slice", subject: pendingSlice.id } : undefined;
36
36
  }
37
37
 
38
38
  // The single answer to "what happens next". Both `factory status` and the opencode
@@ -42,23 +42,35 @@ function nextSliceAction(slices) {
42
42
  // Resume: the first thing a returning session needs to know. Preserves the inherited
43
43
  // rules: a pending gate re-presents, a running slice re-observes, an
44
44
  // unaccepted step re-runs.
45
- export function nextAction(run) {
46
- if (["completed", "partial", "blocked"].includes(run.status)) return `terminal:${run.status}`;
45
+ // `{kind, subject}`, because the answer is two facts and a consumer that must split a string on `:` to
46
+ // recover them is parsing a rendering. `subject` is null for the kinds that name no target.
47
+ export function nextActionRecord(run) {
48
+ if (["completed", "partial", "blocked"].includes(run.status)) return { kind: "terminal", subject: run.status };
47
49
  const openStep = run.steps.find((step) => step.status !== "accepted");
50
+ const stepAction = openStep ? { kind: "step", subject: openStep.agent } : null;
48
51
  const sliceAction = nextSliceAction(run.slices);
49
52
  for (const name of GATE_NAMES) {
50
53
  const gate = run.gates[name];
51
54
  // `pending` waits on a human; absent means the phase has not been reached, which is
52
55
  // still not "done". But naming an absent gate while an agent is mid-round reads as
53
56
  // "waiting on you" — so existing slice or step work is named instead.
54
- if (gate === undefined) return sliceAction ?? (openStep ? `step:${openStep.agent}` : `gate:${name}`);
55
- if (gate.status === "pending") return `gate:${name}`;
56
- if (gate.status === "stop") return `stopped-at-gate:${name}`;
57
- if (gate.status === "changes") return `changes-at-gate:${name}`;
58
- if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return "seed-slices";
57
+ if (gate === undefined) return sliceAction ?? stepAction ?? { kind: "gate", subject: name };
58
+ if (gate.status === "pending") return { kind: "gate", subject: name };
59
+ if (gate.status === "stop") return { kind: "stopped-at-gate", subject: name };
60
+ if (gate.status === "changes") return { kind: "changes-at-gate", subject: name };
61
+ if (name === "brief" && gate.status === "approved" && run.slices.length === 0) return { kind: "seed-slices", subject: null };
59
62
  }
60
63
  if (sliceAction) return sliceAction;
61
- if (openStep) return `step:${openStep.agent}`;
62
- if (!run.pr_url) return "pr";
63
- return "complete";
64
+ if (stepAction) return stepAction;
65
+ if (!run.pr_url) return { kind: "pr", subject: null };
66
+ return { kind: "complete", subject: null };
67
+ }
68
+
69
+ // The string form is DERIVED from the record rather than computed beside it, so the two cannot disagree.
70
+ // Retained because the driver contract, the sidebar and a great deal of prose all name `next: gate:story`,
71
+ // and because a human-facing label is a fair thing for a CLI to keep -- as long as it is a projection of
72
+ // the structured answer and not a second implementation of it.
73
+ export function nextAction(run) {
74
+ const { kind, subject } = nextActionRecord(run);
75
+ return subject === null ? kind : `${kind}:${subject}`;
64
76
  }