feature-factory 0.8.7 → 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/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";
@@ -972,12 +972,31 @@ const HANDLERS = {
972
972
  pr_draft: run.pr_draft ?? true,
973
973
  lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
974
974
  lock_session: lock.owner?.session ?? null,
975
- gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name]).map((name) => [name, run.gates[name].status])),
976
- steps: run.steps.map((step) => `${step.agent}:${step.status}(${step.attempts})`),
977
- slices: run.slices.map((slice) => `${slice.id}:${slice.status}(${slice.attempts})`),
978
- 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,
979
994
  pr_url: run.pr_url,
980
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),
981
1000
  next: nextAction(run),
982
1001
  });
983
1002
  },
@@ -1172,7 +1191,8 @@ const HANDLERS = {
1172
1191
  });
1173
1192
  if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
1174
1193
  return emit(flags, {
1175
- 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),
1176
1196
  });
1177
1197
  },
1178
1198
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-factory",
3
- "version": "0.8.7",
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
  }