feature-factory 0.7.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/bin/factory.js ADDED
@@ -0,0 +1,1499 @@
1
+ #!/usr/bin/env node
2
+ // False-green enforcement: parked preflights precede effects; every mutation uses checked atomic CAS.
3
+ // Initialization alone creates run.json through atomic no-clobber publication.
4
+ // The orchestrator calls this CLI instead of writing control-plane state directly.
5
+ // Flags are declared per command; unknown options fail rather than becoming missing fields.
6
+ // Schema validation surrounds every state write.
7
+ import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { createHash } from "node:crypto";
11
+ import { isDeepStrictEqual } from "node:util";
12
+ import { readFileSync } from "node:fs";
13
+ import { nextAction, readRun, readRunUnchecked } from "../state/index.js";
14
+ import { transition } from "../state/transition.js";
15
+ import { buildEvidence, deriveReviewReady, EVIDENCE_KEYS, evidenceRef, git, observeAncestry, observeCleanliness, observeTrackedCleanliness, observeWorktree, privilegedPaths, proveInitContainment, resolveWorktree, runBootstrap, unownedPaths } from "../observe/index.js";
16
+ import { assertPublicationReady, assertReviewBinding, observeMergeProof, readEvidence, readReview, readValidatorReview } from "../observe/review.js";
17
+ import { readRepositoryConfig, RepositoryConfigError } from "../observe/repository-config.js";
18
+ import { reverifyRepair } from "../observe/repair-reverification.js";
19
+ import { archiveReviewAttempt } from "../state/review-archive.js";
20
+ import { writeProtectedJsonAtomic } from "../core/atomic-write.js";
21
+ import { enforceEffectivePushTarget } from "../core/effective-push.js";
22
+ import { resolveSpawnExecutable } from "../core/executable.js";
23
+ import { dispatchInitPublication } from "./init-publication.js";
24
+ import { CONTROL_PLANE, SCHEMA_VERSION, GATE_NAMES, GATE_STATUSES, MODES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES, repositoryRelativePath, validateRun } from "../state/schema.js";
25
+ import {
26
+ claimSessionLock, inspectSessionLock, refreshSessionLock, releaseSessionLock, SessionLockHeldError,
27
+ } from "../state/session-lock.js";
28
+
29
+ export const COMMANDS = Object.freeze({
30
+ init: Object.freeze(["--repo", "--branch", "--worktree", "--pr-base", "--issue", "--mode", "--max-parallel-slices", "--max-retries", "--now", "--json"]),
31
+ status: Object.freeze(["--repo", "--json"]),
32
+ "amend-paths": Object.freeze(["--repo", "--add", "--reason", "--session", "--now", "--json"]),
33
+ resume: Object.freeze(["--repo", "--session", "--now", "--json"]),
34
+ // No --force: `lock <id> steal` is the same operation with a name that says what it
35
+ // does, and two spellings of "take someone else's lock" is one too many.
36
+ lock: Object.freeze(["--repo", "--session", "--branch", "--ttl-ms", "--now", "--json"]),
37
+ heartbeat: Object.freeze(["--repo", "--session", "--now", "--json"]),
38
+ gate: Object.freeze(["--repo", "--artifact", "--now", "--json"]),
39
+ step: Object.freeze(["--repo", "--attempts", "--review-ref", "--evidence-ref", "--now", "--json"]),
40
+ terminal: Object.freeze(["--repo", "--reason", "--now", "--json"]),
41
+ "slices-seed": Object.freeze(["--repo", "--from", "--now", "--json"]),
42
+ slice: Object.freeze(["--repo", "--attempts", "--worktree", "--branch", "--evidence-ref", "--review-ref", "--merge-commit", "--now", "--json"]),
43
+ // No --skip-tests-reason: whether a slice needs tests is ratified in its test_plan at
44
+ // seeding, not asserted at observation time by the party being observed.
45
+ observe: Object.freeze(["--repo", "--worktree", "--base", "--attempt", "--test-cmd", "--repository-verify", "--claim", "--status", "--blocked-reason", "--now", "--json"]),
46
+ validator: Object.freeze(["--repo", "--report", "--now", "--json"]),
47
+ pr: Object.freeze(["--repo", "--url", "--now", "--json"]),
48
+ "reverify-repair": Object.freeze(["--repo", "--now", "--json"]),
49
+ "effective-push": Object.freeze([]),
50
+ });
51
+
52
+ const BOOLEAN_FLAGS = new Set(["--json", "--repository-verify"]);
53
+ const INIT_OPERATIONS = Object.freeze({
54
+ cwd: () => process.cwd(), resolvePath: resolve, joinPath: join,
55
+ realpath: realpathSync, lstat: lstatSync, mkdir: mkdirSync, readdir: readdirSync,
56
+ runGit: git, prove: proveInitContainment, publish: dispatchInitPublication,
57
+ });
58
+
59
+ class CliError extends Error {
60
+ constructor(message, options) {
61
+ super(message, options);
62
+ this.name = "CliError";
63
+ }
64
+ }
65
+
66
+ export async function run(argv) {
67
+ const [command, ...rest] = argv;
68
+ if (!command || command === "--help" || command === "-h") return usage();
69
+ if (!Object.hasOwn(COMMANDS, command)) throw new CliError(`unknown command '${command}' (try --help)`);
70
+ const { positional, flags } = parse(command, rest);
71
+ const handler = HANDLERS[command];
72
+ return handler(positional, flags);
73
+ }
74
+
75
+ function parse(command, args) {
76
+ const allowed = COMMANDS[command];
77
+ const positional = [];
78
+ const flags = {};
79
+ for (let index = 0; index < args.length; index += 1) {
80
+ const arg = args[index];
81
+ if (!arg.startsWith("--")) {
82
+ positional.push(arg);
83
+ continue;
84
+ }
85
+ if (!allowed.includes(arg)) throw new CliError(`unknown option '${arg}' for '${command}'`);
86
+ if (BOOLEAN_FLAGS.has(arg)) {
87
+ flags[key(arg)] = true;
88
+ continue;
89
+ }
90
+ const value = args[index + 1];
91
+ if (value === undefined || value.startsWith("--")) throw new CliError(`${arg} requires a value`);
92
+ const flagKey = key(arg);
93
+ if (arg === "--add") flags[flagKey] = [...(flags[flagKey] ?? []), value];
94
+ else flags[flagKey] = value;
95
+ index += 1;
96
+ }
97
+ return { positional, flags };
98
+ }
99
+
100
+ const key = (flag) => flag.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase());
101
+
102
+ function planDigest(bytes) {
103
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
104
+ }
105
+
106
+ // Enforcement, not instruction: an entry observe cannot execute has no legal move once seeded.
107
+ // Whole tokens, never a substring scan -- observe spawns with `shell: false`, so a metacharacter inside an
108
+ // argv element is inert payload, and scanning every character refuses commands that run fine. Quotes are
109
+ // therefore absent; `>>`/`<<` are listed because token equality does not imply `>` covers them. The
110
+ // end-to-end rows record which real command a substring scan mangled.
111
+ const REFUSED_TEST_TOKENS = Object.freeze(["&&", "||", ";", "|", "&", "<", ">", ">>", "<<"]);
112
+ function assertExecutableTestPlan(slices, cwd, missingOnly = false) {
113
+ for (const slice of slices) for (const entry of Array.isArray(slice.test_plan) ? slice.test_plan : []) {
114
+ if (typeof entry !== "string") continue;
115
+ const prefix = `slice '${slice.id}' test_plan entry ${JSON.stringify(entry)} cannot be executed by observe as argv without a shell: `;
116
+ const tokens = entry.split(" ").filter(Boolean);
117
+ const argv0 = tokens[0];
118
+ if (!argv0) throw new Error(`${prefix}argv[0] is missing`);
119
+ if (missingOnly) continue;
120
+ const refused = tokens.find((token) => REFUSED_TEST_TOKENS.includes(token));
121
+ if (refused) throw new Error(`${prefix}contains shell operator token ${JSON.stringify(refused)}`);
122
+ const found = resolveSpawnExecutable(argv0, { cwd });
123
+ if (found.reason === "unsupported-platform") throw new Error(`${prefix}argv[0] resolution is POSIX-only and cannot predict this platform's shell-free spawn (${found.platform}); seeding refuses rather than admit a command observe may fail to run`);
124
+ if (!found.ok) throw new Error(`${prefix}argv[0] ${JSON.stringify(argv0)} did not resolve to an executable via ${found.source} from repository cwd ${JSON.stringify(cwd)}`);
125
+ }
126
+ }
127
+
128
+ // Read when the gate is *presented*, not when it is approved. Approval-time hashing left a window:
129
+ // present plan A, edit plan/slices.json to plan B while the gate is still pending, approve, and the
130
+ // approval hashes B - so the digest proved the seed matched what the approval command read rather
131
+ // than what a human was shown. Presenting is the moment the plan is put in front of somebody, and
132
+ // the skill already says to reopen the gate to `pending` before mutating the plan, so a legitimate
133
+ // revision re-presents and re-binds. A presentation with no plan file is refused: the plan is the
134
+ // artifact that gate exists to review.
135
+ function presentedPlanDigest(runDir) {
136
+ try {
137
+ return planDigest(readFileSync(join(runDir, "plan/slices.json")));
138
+ } catch (error) {
139
+ throw new CliError(`could not read plan/slices.json to bind the brief presentation: ${error.message}`);
140
+ }
141
+ }
142
+
143
+ // `pending` binds the presented bytes. `approved` keeps that binding and refuses if the file has
144
+ // moved since, so the window between presentation and decision is closed rather than re-hashed.
145
+ // Any other decision clears it: nothing is bound until a plan is presented again.
146
+ function briefDigestFor(decision, state, runDir) {
147
+ if (decision === "pending") return presentedPlanDigest(runDir);
148
+ if (decision !== "approved") return null;
149
+ if (!state.plan_digest) throw new CliError("the brief gate was not presented; move it to pending first");
150
+ if (state.plan_digest !== presentedPlanDigest(runDir)) {
151
+ throw new CliError("plan/slices.json changed since the brief gate was presented; re-present it before approving");
152
+ }
153
+ return state.plan_digest;
154
+ }
155
+
156
+ function runDirFor(flags, runId) {
157
+ if (!runId) throw new CliError("a <run-id> is required");
158
+ return join(resolve(flags.repo ?? process.cwd()), CONTROL_PLANE, runId);
159
+ }
160
+
161
+ function assertRunNotParked(runDir, command) {
162
+ const run = readRun(runDir);
163
+ if (run.status === "needs-human") {
164
+ throw new CliError(`factory ${command} refuses while run status is needs-human; run factory resume first`);
165
+ }
166
+ return run;
167
+ }
168
+
169
+ function assertFreshSessionOwner(runDir, runId, session, command) {
170
+ const held = inspectSessionLock(runDir);
171
+ if (held.state === "absent") {
172
+ throw new CliError(`factory ${command} requires a held session lock for run '${runId}'; claim it with 'lock ${runId} claim --session ${session}'`);
173
+ }
174
+ if (held.state === "stale") {
175
+ throw new CliError(`factory ${command} refuses a stale session lock for run '${runId}' (owner ${held.owner.session}, heartbeat ${held.owner.heartbeat_at}); take it with 'lock ${runId} steal --session ${session}'`);
176
+ }
177
+ if (held.owner.session !== session) {
178
+ throw new CliError(`run '${runId}' is held by session ${held.owner.session}, not ${session}; take it with 'lock ${runId} steal --session ${session}'`);
179
+ }
180
+ return held.owner;
181
+ }
182
+
183
+ function sameSessionOwner(runDir, bound) {
184
+ const held = inspectSessionLock(runDir);
185
+ if (held.state !== "fresh" || Date.parse(held.owner.heartbeat_at) < Date.parse(bound.heartbeat_at)) return false;
186
+ return ["session", "run_id", "branch", "claimed_at", "pid"]
187
+ .every((keyName) => isDeepStrictEqual(held.owner[keyName], bound[keyName]));
188
+ }
189
+
190
+ function validatePathAdditions(slice, additions) {
191
+ for (const path of additions) {
192
+ if (!repositoryRelativePath(path)) {
193
+ throw new CliError(`added path '${path}' must be non-empty, repository-relative, and contain no '..' segment`);
194
+ }
195
+ }
196
+ const privileged = privilegedPaths(additions);
197
+ if (privileged.length > 0) throw new CliError(`cannot amend privileged control-plane paths: ${privileged.join(", ")}`);
198
+ const seen = new Set();
199
+ for (const path of additions) {
200
+ if (seen.has(path)) throw new CliError(`duplicate requested path '${path}'`);
201
+ seen.add(path);
202
+ }
203
+ for (const path of additions) {
204
+ if (unownedPaths([path], slice.paths).length === 0) {
205
+ throw new CliError(`slice '${slice.id}' already owns requested path '${path}'`);
206
+ }
207
+ }
208
+ }
209
+
210
+ // The integration branch's worktree and currently observed head. Three call sites asked
211
+ // this in four lines each with slightly different wording. The branch is named explicitly
212
+ // rather than observed as HEAD: recording a merge legitimately runs with a different
213
+ // branch checked out, and binding to whatever happens to be there makes the observation
214
+ // depend on the orchestrator's directory state.
215
+ //
216
+ // `commit` may be null — an unobservable head is a different refusal at each call site,
217
+ // so the decision stays with the caller rather than being flattened here.
218
+ function integrationHead(repo, run) {
219
+ const worktree = resolveWorktree(repo, run.worktree);
220
+ if (!worktree) throw new CliError(`integration worktree '${run.worktree}' is not observable`);
221
+ return { worktree, commit: observeWorktree(worktree, run.branch, { ref: run.branch }).commit };
222
+ }
223
+
224
+ function requireIntegrationWorktree(repo, run, suppliedWorktree) {
225
+ let repository;
226
+ let committed;
227
+ let supplied;
228
+ try {
229
+ repository = realpathSync(resolve(repo));
230
+ const committedPath = resolveWorktree(repository, run.worktree);
231
+ const suppliedPath = resolveWorktree(repository, suppliedWorktree);
232
+ committed = committedPath ? realpathSync(committedPath) : null;
233
+ supplied = suppliedPath ? realpathSync(suppliedPath) : null;
234
+ } catch {
235
+ committed = null;
236
+ supplied = null;
237
+ }
238
+ if (!repository || !committed || !supplied || committed !== supplied
239
+ || resolveWorktree(repository, committed) !== committed || resolveWorktree(repository, supplied) !== supplied) {
240
+ throw new CliError(`integration worktree mismatch: committed '${run.worktree}' resolves to '${committed ?? "unobservable"}', supplied '${suppliedWorktree}' resolves to '${supplied ?? "unobservable"}'`);
241
+ }
242
+ const branch = git(committed, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
243
+ const observedBranch = branch.ok && branch.stdout.trim() ? branch.stdout.trim() : "detached HEAD";
244
+ if (observedBranch !== run.branch) {
245
+ throw new CliError(`integration worktree must have branch '${run.branch}' checked out; observed ${observedBranch}`);
246
+ }
247
+ const head = git(committed, ["rev-parse", "--verify", "HEAD^{commit}"]);
248
+ const tip = git(committed, ["rev-parse", "--verify", `refs/heads/${run.branch}^{commit}`]);
249
+ const headSha = head.ok ? head.stdout.trim() : "";
250
+ const tipSha = tip.ok ? tip.stdout.trim() : "";
251
+ if (!/^[0-9a-f]{40}$/u.test(headSha) || !/^[0-9a-f]{40}$/u.test(tipSha) || headSha !== tipSha) {
252
+ throw new CliError(`integration HEAD must equal the current recorded branch tip for '${run.branch}'`);
253
+ }
254
+ return { worktree: committed, head: headSha };
255
+ }
256
+
257
+ function bootstrapOutcome(worktree, config, phase) {
258
+ const exit = runBootstrap(worktree, config.bootstrapCommand, config.bootstrapTimeoutMs);
259
+ const tracked = observeTrackedCleanliness(worktree);
260
+ let refusal = null;
261
+ if (!tracked.observed) refusal = `factory config entry 'bootstrap' could not observe tracked paths after ${phase}`;
262
+ else if (tracked.entries.length) refusal = `factory config entry 'bootstrap' left tracked paths dirty after ${phase}: ${tracked.entries.map(JSON.stringify).join(", ")}`;
263
+ else if (exit !== 0) refusal = exit === null
264
+ ? `factory config entry 'bootstrap' failed during ${phase}; exit status unavailable`
265
+ : `factory config entry 'bootstrap' failed during ${phase} with exit status ${exit}`;
266
+ return { exit, refusal };
267
+ }
268
+
269
+ function branchPoint(run) {
270
+ const base = run.slices.find((slice) => Array.isArray(slice.depends_on) && slice.depends_on.length === 0)?.base_ref;
271
+ if (!/^[0-9a-f]{40}$/u.test(base ?? "")) throw new CliError("first seeded root slice has no immutable 40-character base_ref");
272
+ return base;
273
+ }
274
+
275
+ async function writeObservedEvidence({ runDir, runId, subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, testCommand, skipReason, shellCommand, testTimeoutMs }) {
276
+ const evidence = buildEvidence({
277
+ subject, attempt, branch, baseRef, worktree, status, blockedReason, claim, runId,
278
+ testCommand, skipReason, shellCommand, testTimeoutMs,
279
+ });
280
+ const ancestry = observeAncestry(worktree, baseRef, "HEAD");
281
+ if (ancestry !== "ancestor") {
282
+ evidence.review_ready = false;
283
+ evidence.blocked_reason = evidence.blocked_reason ?? `base ${baseRef} is ${ancestry} of HEAD`;
284
+ }
285
+ await writeProtectedJsonAtomic(runDir, evidenceRef(subject), evidence);
286
+ return { evidence, ancestry };
287
+ }
288
+
289
+ function canonicalRepositoryVerifyEvidence(evidence, { runId, run, integration, verifyCommand }) {
290
+ const baseRef = branchPoint(run);
291
+ const keys = Object.keys(evidence).sort();
292
+ const commandNames = [
293
+ "git rev-parse HEAD",
294
+ `git --literal-pathspecs diff --name-only -z ${baseRef}...HEAD`,
295
+ `git diff --stat ${baseRef}...HEAD`,
296
+ ];
297
+ const commandsAreCanonical = Array.isArray(evidence.commands)
298
+ && evidence.commands.length === commandNames.length
299
+ && evidence.commands.every((command, index) => command && typeof command === "object" && !Array.isArray(command)
300
+ && JSON.stringify(Object.keys(command).sort()) === JSON.stringify(["cmd", "exit", "summary"])
301
+ && command.cmd === commandNames[index] && command.exit === 0 && typeof command.summary === "string");
302
+ const tests = evidence.tests;
303
+ const testsAreCanonical = tests && typeof tests === "object" && !Array.isArray(tests)
304
+ && JSON.stringify(Object.keys(tests).sort()) === JSON.stringify(["cmd", "exit", "observed", "skipped_reason"])
305
+ && tests.cmd === verifyCommand && typeof tests.observed === "boolean" && tests.skipped_reason === null
306
+ && ((tests.observed === true && Number.isInteger(tests.exit))
307
+ || (tests.observed === false && tests.exit === null));
308
+ const reconciliation = evidence.claim_reconciliation;
309
+ return JSON.stringify(keys) === JSON.stringify([...EVIDENCE_KEYS].sort())
310
+ && evidence.subject === "test-verifier" && evidence.run_id === runId
311
+ && Number.isSafeInteger(evidence.attempt) && evidence.attempt >= 1
312
+ && evidence.branch === run.branch && evidence.base_ref === baseRef
313
+ && evidence.worktree === integration.worktree && evidence.status === "completed"
314
+ && typeof evidence.worktree_clean === "boolean"
315
+ && ((evidence.worktree_clean && evidence.blocked_reason === null)
316
+ || (!evidence.worktree_clean && typeof evidence.blocked_reason === "string" && Boolean(evidence.blocked_reason.trim())))
317
+ && Array.isArray(evidence.files_changed) && evidence.files_changed.length > 0
318
+ && evidence.files_changed.every((path) => typeof path === "string" && Boolean(path))
319
+ && typeof evidence.diff_stat === "string" && evidence.diff_observed === true
320
+ && commandsAreCanonical && testsAreCanonical && evidence.commit === integration.head
321
+ && evidence.observed_by === "orchestrator"
322
+ // The value, not the type. `readEvidence` above already refuses a record whose stored
323
+ // review_ready disagrees with its contents, so no such record reaches here today; this
324
+ // keeps the predicate that decides replay-eligibility from being correct only by virtue
325
+ // of its caller.
326
+ && evidence.review_ready === deriveReviewReady(evidence)
327
+ && reconciliation && typeof reconciliation === "object" && !Array.isArray(reconciliation)
328
+ && JSON.stringify(Object.keys(reconciliation).sort()) === JSON.stringify(["claimed", "mismatches"])
329
+ && reconciliation.claimed === false && Array.isArray(reconciliation.mismatches)
330
+ && reconciliation.mismatches.length === 0;
331
+ }
332
+
333
+ function classifyRepositoryVerifyEvidence(runDir, context) {
334
+ let evidence;
335
+ try {
336
+ evidence = readEvidence(runDir, evidenceRef("test-verifier"), { runId: context.runId });
337
+ } catch {
338
+ return { kind: "unknown", evidence: null };
339
+ }
340
+ // False-green enforcement: only complete canonical evidence bound to this merge may be reused or retried.
341
+ if (!canonicalRepositoryVerifyEvidence(evidence, context)) {
342
+ return { kind: "unknown", evidence };
343
+ }
344
+ if (evidence.tests.observed === true && evidence.tests.exit === 0 && evidence.review_ready === true) {
345
+ return { kind: "green", evidence };
346
+ }
347
+ if (evidence.tests.observed === true && Number.isInteger(evidence.tests.exit)) {
348
+ return { kind: "failed", evidence };
349
+ }
350
+ if (evidence.tests.observed === false && evidence.tests.exit === null && evidence.tests.skipped_reason === null) {
351
+ return { kind: "unavailable", evidence };
352
+ }
353
+ return { kind: "unknown", evidence };
354
+ }
355
+
356
+ function repositoryVerifyRefusal(mergeCommit, evidence) {
357
+ if (evidence.tests?.observed === true && Number.isInteger(evidence.tests.exit) && evidence.tests.exit !== 0) {
358
+ return `factory config entry 'verify' failed after recorded merge ${mergeCommit} with exit status ${evidence.tests.exit}; merged slice remains recorded; stop before advancing.`;
359
+ }
360
+ if (evidence.tests?.exit === null || evidence.tests?.observed !== true) {
361
+ return `factory config entry 'verify' failed after recorded merge ${mergeCommit}; exit status unavailable; merged slice remains recorded; stop before advancing.`;
362
+ }
363
+ return `factory config entry 'verify' was not review_ready after recorded merge ${mergeCommit}: ${evidence.blocked_reason ?? "review readiness was not established"}; merged slice remains recorded; stop before advancing.`;
364
+ }
365
+
366
+ function mergedPayload(runId, sliceId, row) {
367
+ return { run_id: runId, slice: sliceId, status: row.status, attempts: row.attempts, base_ref: row.base_ref, merge_commit: row.merge_commit };
368
+ }
369
+
370
+ function repositoryVerifyUnknownRefusal(mergeCommit) {
371
+ return `post-merge verify outcome is unknown for recorded merge ${mergeCommit}; merged slice remains recorded; terminalize needs-human without re-executing factory config entry 'verify'.`;
372
+ }
373
+
374
+ function repositoryVerifyRetrySafety(repo, run, mergeCommit) {
375
+ // False-green enforcement: retries may test only the unchanged, clean bytes recorded by the merge.
376
+ let integration;
377
+ try {
378
+ integration = requireIntegrationWorktree(repo, run, run.worktree);
379
+ } catch (error) {
380
+ throw new CliError(`repository verification retry is unsafe after recorded merge ${mergeCommit}: ${error.message}; merged slice remains recorded; stop before advancing.`);
381
+ }
382
+ if (integration.head !== mergeCommit) {
383
+ throw new CliError(`repository verification retry is unsafe after recorded merge ${mergeCommit}: integration HEAD moved to ${integration.head}; merged slice remains recorded; stop before advancing.`);
384
+ }
385
+ const cleanliness = observeCleanliness(integration.worktree);
386
+ if (!cleanliness.clean) {
387
+ throw new CliError(`repository verification retry is unsafe after recorded merge ${mergeCommit}: ${cleanliness.reason}; merged slice remains recorded; stop before advancing.`);
388
+ }
389
+ return integration;
390
+ }
391
+
392
+ async function runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration }) {
393
+ const baseRef = branchPoint(run);
394
+ let attemptIntegration = integration;
395
+ // False-green enforcement: one invocation gets at most two executions, never an unbounded recovery loop.
396
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
397
+ const { evidence } = await writeObservedEvidence({
398
+ runDir, runId, subject: "test-verifier", attempt, branch: run.branch,
399
+ baseRef, worktree: attemptIntegration.worktree, status: "completed", blockedReason: null,
400
+ claim: null, testCommand: verify.command, skipReason: null, shellCommand: true,
401
+ testTimeoutMs: verify.timeoutMs,
402
+ });
403
+ const classified = classifyRepositoryVerifyEvidence(runDir, {
404
+ runId, run, integration: attemptIntegration, verifyCommand: verify.command,
405
+ });
406
+ if (classified.kind === "green") return evidence;
407
+ if (classified.kind === "failed") throw new CliError(repositoryVerifyRefusal(mergeCommit, classified.evidence));
408
+ if (classified.kind === "unknown") throw new CliError(repositoryVerifyUnknownRefusal(mergeCommit));
409
+ if (attempt === 2) throw new CliError(repositoryVerifyRefusal(mergeCommit, evidence));
410
+ attemptIntegration = repositoryVerifyRetrySafety(repo, run, mergeCommit);
411
+ }
412
+ return null;
413
+ }
414
+
415
+ async function verifyRecordedMerge({ repo, runDir, runId, mergeCommit }) {
416
+ const run = readRun(runDir);
417
+ const integration = requireIntegrationWorktree(repo, run, run.worktree);
418
+ if (integration.head !== mergeCommit) {
419
+ throw new CliError(`integration HEAD must equal recorded merge ${mergeCommit} before repository verification`);
420
+ }
421
+ let verify;
422
+ try {
423
+ verify = readRepositoryConfig(integration.worktree, { optional: true });
424
+ } catch (error) {
425
+ if (error instanceof RepositoryConfigError) {
426
+ throw new CliError(`factory config entry 'verify' unavailable after recorded merge ${mergeCommit}: ${error.message}; merged slice remains recorded; stop before advancing.`);
427
+ }
428
+ throw error;
429
+ }
430
+ if (verify === null) return null;
431
+ return runRepositoryVerifyAttempts({ repo, runDir, runId, run, mergeCommit, verify, integration });
432
+ }
433
+
434
+ const HANDLERS = {
435
+ async ["reverify-repair"](positional, flags) {
436
+ if (positional.length !== 2) throw new CliError("factory reverify-repair requires exactly <run-id> <repair-record-id>");
437
+ const [runId, recordId] = positional;
438
+ if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(runId)) throw new CliError("factory reverify-repair requires a canonical <run-id>");
439
+ const record = /^repair-([0-9a-f]{40})-([1-9][0-9]*)$/u.exec(recordId);
440
+ if (!record || !Number.isSafeInteger(Number(record[2]))) throw new CliError("factory reverify-repair requires a canonical <repair-record-id>");
441
+ if (flags.repo !== undefined && (typeof flags.repo !== "string" || !flags.repo.trim())) throw new CliError("--repo must be a non-empty string");
442
+ const at = stamp(flags);
443
+ const repo = resolve(flags.repo ?? process.cwd());
444
+ const runDir = runDirFor(flags, runId);
445
+ return emit(flags, await reverifyRepair({ repo, runDir, runId, recordId, at }));
446
+ },
447
+
448
+ "effective-push"(positional) {
449
+ enforceEffectivePushTarget(positional);
450
+ return null;
451
+ },
452
+
453
+ async validator([runId], flags) {
454
+ if (!flags.report) throw new CliError("factory validator requires --report");
455
+ const runDir = runDirFor(flags, runId);
456
+ const run = assertRunNotParked(runDir, "validator");
457
+ const repo = resolve(flags.repo ?? process.cwd());
458
+ const at = stamp(flags);
459
+ // Neither the verdict nor the head is an argument any more. Both come from the
460
+ // validator's own record, which must name the integration head as observed right now —
461
+ // otherwise a report about one commit could be recorded as a verdict on another.
462
+ const head = integrationHead(repo, run);
463
+ const review = readValidatorReview(runDir, head.commit);
464
+ const next = await transition(runDir, {
465
+ participants: [{ familyId: "verdict", mode: "record" }],
466
+ apply: (state) => ({
467
+ ...state,
468
+ updated_at: at,
469
+ validator: {
470
+ verdict: review.verdict,
471
+ report: flags.report,
472
+ // Attack 4: the verdict names the head it judged, so a later consumer can
473
+ // refuse it once that head moves.
474
+ reviewed_head: review.reviewed_commit,
475
+ loops: (state.validator?.loops ?? 0) + (state.validator ? 1 : 0),
476
+ },
477
+ }),
478
+ });
479
+ return emit(flags, { run_id: runId, verdict: next.validator.verdict, reviewed_head: next.validator.reviewed_head, loops: next.validator.loops });
480
+ },
481
+
482
+ async pr([runId], flags) {
483
+ if (!flags.url) throw new CliError("factory pr requires --url");
484
+ const runDir = runDirFor(flags, runId);
485
+ assertRunNotParked(runDir, "pr");
486
+ const repo = resolve(flags.repo ?? process.cwd());
487
+ const run = readRun(runDir);
488
+ const at = stamp(flags);
489
+
490
+ // Re-asked rather than assumed from Gate 3's approval: between the two the head can
491
+ // move, a slice can regress, and a gate can be re-opened, and `pr` is where the record
492
+ // becomes permanent. The rules live in one place, including the requirement that all
493
+ // three gates are approved *now* — this handler used to check pre_pr on its own, which
494
+ // is how a re-opened Story gate went unnoticed.
495
+ const reobservers = new Map();
496
+ reobservers.set("verdict", async ({ nextState }) => {
497
+ assertPublicationReady({
498
+ runDir, state: nextState, runId, repo,
499
+ observeHead: () => integrationHead(repo, nextState).commit,
500
+ });
501
+ });
502
+
503
+ const next = await transition(runDir, {
504
+ participants: [{ familyId: "verdict", mode: "publish" }],
505
+ reobservers,
506
+ apply: (state) => {
507
+ // Attacks 9 and 10: recording the same PR twice is the crash-replay path and
508
+ // must be idempotent; recording a different one is a second PR and is refused
509
+ // by the verdict contract.
510
+ if (state.pr_url === flags.url) return { ...state, updated_at: at };
511
+ return { ...state, updated_at: at, pr_url: flags.url };
512
+ },
513
+ });
514
+ return emit(flags, { run_id: runId, pr_url: next.pr_url, idempotent: run.pr_url === flags.url });
515
+ },
516
+
517
+ async ["slices-seed"]([runId], flags) {
518
+ const runDir = runDirFor(flags, runId);
519
+ assertRunNotParked(runDir, "slices-seed");
520
+ const from = flags.from ?? "plan/slices.json";
521
+ let bytes;
522
+ try {
523
+ bytes = readFileSync(join(runDir, from));
524
+ } catch (error) {
525
+ throw new CliError(`could not read ${from}: ${error.message}`);
526
+ }
527
+ let plan;
528
+ try {
529
+ plan = JSON.parse(bytes.toString("utf8"));
530
+ } catch (error) {
531
+ throw new CliError(`could not read ${from}: ${error.message}`);
532
+ }
533
+ if (!Array.isArray(plan?.slices)) throw new CliError(`${from} must have top-level shape { "slices": [...] }`);
534
+ if (plan.slices.length === 0) throw new CliError(`${from} has no slices`);
535
+ const at = stamp(flags);
536
+ const next = await transition(runDir, {
537
+ participants: [{ familyId: "slices", mode: "seed" }],
538
+ apply: (state) => {
539
+ if (state.slices.length > 0) throw new Error("slices are already seeded");
540
+ // The gate approved bytes, not a filename. Without this the ordering fix is prose: a plan
541
+ // revised after approval seeds unreviewed `paths` and `test_plan`, immutable from here, an
542
+ // empty test_plan among them. Absent digest refuses rather than waves through - re-approving
543
+ // the brief gate records one, and the gate may re-open while the plan is unseeded.
544
+ // Guarded on approval so the gates contract keeps its own refusal when the gate is not
545
+ // approved at all; this one answers "approved, but is this what was approved".
546
+ if (state.gates.brief?.status === "approved") {
547
+ if (!state.plan_digest) throw new Error("the brief gate approved no plan digest; re-approve it before seeding");
548
+ if (state.plan_digest !== planDigest(bytes)) throw new Error(`${from} is not the plan the brief gate approved`);
549
+ }
550
+ if (state.gates.brief?.status !== "approved") throw new Error("slices-seed requires the Brief gate to be approved");
551
+ const candidate = {
552
+ ...state,
553
+ updated_at: at,
554
+ slices: plan.slices.map((slice) => ({
555
+ id: slice.id,
556
+ stack: slice.stack,
557
+ depends_on: slice.depends_on ?? [],
558
+ status: "pending",
559
+ worktree: null,
560
+ branch: null,
561
+ attempts: 1,
562
+ // The ratification point: the gate approved these paths and this test plan,
563
+ // so they are the set every later merge is judged against and the decision
564
+ // about whether this slice may ship without an observed test run.
565
+ //
566
+ // Stored with NO default. `test_plan ?? []` turned an omitted field into the
567
+ // approved-empty exemption, so a plan that never mentioned tests silently
568
+ // waived them - the CLI defeating the schema rule that was supposed to make
569
+ // that impossible. The schema rejects a missing or non-array value.
570
+ paths: slice.paths,
571
+ path_amendments: [],
572
+ test_plan: slice.test_plan,
573
+ evidence_ref: null,
574
+ review_ref: null,
575
+ merge_commit: null,
576
+ })),
577
+ };
578
+ assertExecutableTestPlan(candidate.slices, resolve(flags.repo ?? process.cwd()), true);
579
+ validateRun(candidate);
580
+ // Enforcement: refuse a ratified false green with no executable legal move.
581
+ assertExecutableTestPlan(candidate.slices, resolve(flags.repo ?? process.cwd()));
582
+ return candidate;
583
+ },
584
+ });
585
+ return emit(flags, { run_id: runId, seeded: next.slices.length, slices: next.slices.map((slice) => slice.id) });
586
+ },
587
+
588
+ async ["amend-paths"](positional, flags) {
589
+ if (positional.length !== 2) throw new CliError("factory amend-paths requires exactly <run-id> <slice-id>");
590
+ const [runId, sliceId] = positional;
591
+ if (!Array.isArray(flags.add) || flags.add.length === 0) throw new CliError("factory amend-paths requires at least one --add <path>");
592
+ if (typeof flags.reason !== "string" || !flags.reason.trim()) throw new CliError("factory amend-paths requires nonblank --reason <text>");
593
+ if (typeof flags.session !== "string" || !flags.session.trim()) throw new CliError("factory amend-paths requires nonblank --session <id>");
594
+ const runDir = runDirFor(flags, runId);
595
+ const current = readRun(runDir);
596
+ if (current.status !== "needs-human") {
597
+ throw new CliError(`factory amend-paths requires current status needs-human; found '${current.status}'`);
598
+ }
599
+ assertFreshSessionOwner(runDir, runId, flags.session, "amend-paths");
600
+ const at = stamp(flags);
601
+ const reobservers = new Map([["slices", async () => ({
602
+ authorized_session: assertFreshSessionOwner(runDir, runId, flags.session, "amend-paths").session,
603
+ })]]);
604
+ const next = await transition(runDir, {
605
+ participants: [{ familyId: "envelope", mode: "amend-paths" }, { familyId: "slices", mode: "amend-paths" }],
606
+ reobservers,
607
+ apply: (state) => {
608
+ if (state.status !== "needs-human") throw new CliError(`factory amend-paths requires current status needs-human; found '${state.status}'`);
609
+ const existing = state.slices.find((slice) => slice.id === sliceId);
610
+ if (!existing) throw new CliError(`unknown slice '${sliceId}'`);
611
+ if (existing.status === "merged") throw new CliError(`slice '${sliceId}' is already merged`);
612
+ validatePathAdditions(existing, flags.add);
613
+ const amendment = { added_paths: [...flags.add], reason: flags.reason, session: flags.session, at };
614
+ const row = { ...existing, paths: [...existing.paths, ...flags.add], path_amendments: [...(existing.path_amendments ?? []), amendment] };
615
+ return { ...state, updated_at: at, slices: state.slices.map((slice) => (slice.id === sliceId ? row : slice)) };
616
+ },
617
+ });
618
+ const row = next.slices.find((slice) => slice.id === sliceId);
619
+ return emit(flags, { run_id: runId, slice: sliceId, status: next.status,
620
+ terminal_result: next.terminal_result, amendment: row.path_amendments.at(-1) });
621
+ },
622
+
623
+ async slice([runId, sliceId, status], flags) {
624
+ if (!SLICE_STATUSES.includes(status)) throw new CliError(`status must be one of ${SLICE_STATUSES.join(" | ")}`);
625
+ const runDir = runDirFor(flags, runId);
626
+ assertRunNotParked(runDir, "slice");
627
+ const repo = resolve(flags.repo ?? process.cwd());
628
+ const at = stamp(flags);
629
+
630
+ // The branch point is observed rather than supplied, so a stale or convenient base
631
+ // cannot be passed in.
632
+ //
633
+ // A boundary re-observation was added here and reverted: it guarded the window
634
+ // between this read and the commit, but only a second writer of the integration ref
635
+ // can open that window, and there is one orchestrator issuing sequential commands.
636
+ // The parallelism in a wave is between builders, not between writers of the control
637
+ // plane. It bought a retry failure path on correct runs for a race the design does
638
+ // not have. If concurrent orchestrators on one run ever become real, it comes back.
639
+ let observedBase = null;
640
+ if (status === "running") {
641
+ const current = readRun(runDir);
642
+ const head = integrationHead(repo, current);
643
+ if (!head.commit) throw new CliError(`could not observe the head of '${current.branch}' to bind the slice base`);
644
+ observedBase = head.commit;
645
+ }
646
+ if (status === "merged" && !flags.mergeCommit) throw new CliError("recording a merge requires --merge-commit");
647
+
648
+ if (status === "merged") {
649
+ const current = readRun(runDir);
650
+ const existing = current.slices.find((slice) => slice.id === sliceId);
651
+ if (!existing) throw new CliError(`unknown slice '${sliceId}'`);
652
+ if (existing.status === "merged") {
653
+ if (flags.mergeCommit !== existing.merge_commit) {
654
+ throw new CliError(`slice '${sliceId}' is already recorded at immutable merge_commit ${existing.merge_commit}`);
655
+ }
656
+ const integration = requireIntegrationWorktree(repo, current, current.worktree);
657
+ if (integration.head !== existing.merge_commit) {
658
+ throw new CliError(`recorded merge ${existing.merge_commit} replay cannot reconcile the current integration head; do not re-execute factory config entry 'verify'.`);
659
+ }
660
+ let verify;
661
+ try {
662
+ verify = readRepositoryConfig(integration.worktree, { optional: true });
663
+ } catch (error) {
664
+ if (error instanceof RepositoryConfigError) {
665
+ throw new CliError(`factory config entry 'verify' unavailable after recorded merge ${existing.merge_commit}: ${error.message}; merged slice remains recorded; stop before advancing.`);
666
+ }
667
+ throw error;
668
+ }
669
+ if (verify !== null) {
670
+ const classified = classifyRepositoryVerifyEvidence(runDir, {
671
+ runId, run: current, integration, verifyCommand: verify.command,
672
+ });
673
+ if (classified.kind === "failed") throw new CliError(repositoryVerifyRefusal(existing.merge_commit, classified.evidence));
674
+ if (classified.kind === "unknown") {
675
+ throw new CliError(repositoryVerifyUnknownRefusal(existing.merge_commit));
676
+ }
677
+ if (classified.kind === "unavailable") {
678
+ const safeIntegration = repositoryVerifyRetrySafety(repo, current, existing.merge_commit);
679
+ await runRepositoryVerifyAttempts({
680
+ repo, runDir, runId, run: current, mergeCommit: existing.merge_commit, verify,
681
+ integration: safeIntegration,
682
+ });
683
+ }
684
+ }
685
+ return emit(flags, mergedPayload(runId, sliceId, existing));
686
+ }
687
+ }
688
+
689
+ // For a merge, the contract's reobserve hook demands freshly observed paths.
690
+ // The observer is supplied here and runs inside the transition.
691
+ const reobservers = new Map();
692
+ if (status === "merged") {
693
+ reobservers.set("slices", async (slice) => {
694
+ const worktree = resolveWorktree(repo, slice.worktree ?? "");
695
+ if (!worktree) return { diff_observed: false, unowned: [], privileged: [] };
696
+ const run = readRun(runDir);
697
+ // Observe the slice's own branch, not the worktree's current HEAD: recording
698
+ // a merge legitimately happens with the integration branch checked out.
699
+ if (!slice.branch) throw new Error(`slice '${slice.id}' cannot merge without a recorded branch`);
700
+ // Diff from the slice's recorded branch point, not from the integration head:
701
+ // by merge time the integration branch contains the slice, so that diff is
702
+ // empty and ownership would pass vacuously.
703
+ if (!slice.base_ref) throw new Error(`slice '${slice.id}' cannot merge without a recorded base_ref`);
704
+ const observation = observeWorktree(worktree, slice.base_ref, { ref: slice.branch });
705
+
706
+ // Attack 3: the approval must have judged the commit being merged. Read the
707
+ // slice's own head from git rather than trusting anything recorded.
708
+ // Finding 2: `observe` wrote evidence that nothing consumed, so a merge could
709
+ // be recorded with no observed diff and no test run at all - the whole
710
+ // observe-don't-trust mechanism was a write-only side effect. Evidence is now
711
+ // required, must be review_ready, and must describe this slice at this attempt
712
+ // against this base and this head.
713
+ if (!slice.evidence_ref) throw new Error(`slice '${slice.id}' cannot merge without an evidence_ref`);
714
+ const evidence = readEvidence(runDir, slice.evidence_ref, { runId });
715
+ if (evidence.subject !== slice.id) {
716
+ throw new Error(`evidence '${slice.evidence_ref}' describes '${evidence.subject}', not '${slice.id}'`);
717
+ }
718
+ if (evidence.review_ready !== true) {
719
+ throw new Error(`slice '${slice.id}' evidence is not review_ready${evidence.blocked_reason ? `: ${evidence.blocked_reason}` : ""}`);
720
+ }
721
+ if (evidence.attempt !== slice.attempts) {
722
+ throw new Error(`evidence '${slice.evidence_ref}' is for attempt ${evidence.attempt}, slice is at attempt ${slice.attempts}`);
723
+ }
724
+ if (evidence.base_ref !== slice.base_ref) {
725
+ throw new Error(`evidence '${slice.evidence_ref}' observed base ${String(evidence.base_ref).slice(0, 12)}, slice base is ${String(slice.base_ref).slice(0, 12)}`);
726
+ }
727
+ if (!slice.review_ref) throw new Error(`slice '${slice.id}' cannot merge without a review_ref`);
728
+ const review = readReview(runDir, slice.review_ref);
729
+ assertReviewBinding({
730
+ review, ref: slice.review_ref, observedHead: observation.commit,
731
+ subject: slice.id, attempt: slice.attempts,
732
+ });
733
+ if (evidence.commit !== observation.commit) {
734
+ throw new Error(`evidence '${slice.evidence_ref}' observed ${String(evidence.commit).slice(0, 12)} but the slice head is ${String(observation.commit).slice(0, 12)}`);
735
+ }
736
+
737
+ // Attack 2: the merge proof, observed in the integration worktree.
738
+ //
739
+ // Finding 1: the proof validated a caller-supplied object without checking it
740
+ // landed on the integration branch, so a synthetic two-parent merge, or an older
741
+ // valid merge after the branch advanced, both passed. An orchestrator that
742
+ // captured the sha before merging, or reused a stale variable, produces exactly
743
+ // that. The recorded merge must be the branch's current tip.
744
+ const tip = integrationHead(repo, run);
745
+ if (!tip.commit) throw new Error(`could not observe the head of '${run.branch}'`);
746
+ if (tip.commit !== flags.mergeCommit) {
747
+ throw new Error(`merge commit ${String(flags.mergeCommit).slice(0, 12)} is not the head of '${run.branch}' (${tip.commit.slice(0, 12)}); record the merge before advancing the branch`);
748
+ }
749
+ const proof = observeMergeProof(tip.worktree, {
750
+ baseRef: slice.base_ref,
751
+ reviewedCommit: review.reviewed_commit,
752
+ mergeCommit: flags.mergeCommit,
753
+ });
754
+ if (!proof.proven) {
755
+ throw new Error(`slice '${slice.id}' merge proof failed: ${proof.reason}`);
756
+ }
757
+
758
+ return {
759
+ diff_observed: observation.diff_observed,
760
+ unowned: unownedPaths(observation.files_changed, slice.paths),
761
+ privileged: privilegedPaths(observation.files_changed),
762
+ };
763
+ });
764
+ }
765
+
766
+ const next = await transition(runDir, {
767
+ participants: [{ familyId: "slices", mode: status === "merged" ? "merge" : "record" }],
768
+ reobservers,
769
+ apply: (state) => {
770
+ const existing = state.slices.find((slice) => slice.id === sliceId);
771
+ if (!existing) throw new Error(`unknown slice '${sliceId}'`);
772
+ const row = {
773
+ ...existing,
774
+ status,
775
+ attempts: flags.attempts === undefined ? existing.attempts : integer(flags.attempts, 1, "--attempts"),
776
+ worktree: flags.worktree ?? existing.worktree,
777
+ branch: flags.branch ?? existing.branch,
778
+ base_ref: observedBase ?? existing.base_ref,
779
+ evidence_ref: flags.evidenceRef ?? existing.evidence_ref,
780
+ review_ref: flags.reviewRef ?? existing.review_ref,
781
+ merge_commit: flags.mergeCommit ?? existing.merge_commit,
782
+ };
783
+ return { ...state, updated_at: at, slices: state.slices.map((slice) => (slice.id === sliceId ? row : slice)) };
784
+ },
785
+ });
786
+ const row = next.slices.find((slice) => slice.id === sliceId);
787
+ // base_ref is reported because this command is what establishes it, and the very next
788
+ // step needs it: `observe --base` is compared for exact equality against this value at
789
+ // merge time. The skill previously said to read it from `factory status`, which does not
790
+ // expose it — so the documented path could not be followed at all.
791
+ if (status === "merged") await verifyRecordedMerge({ repo, runDir, runId, mergeCommit: row.merge_commit });
792
+ // Slice attempts are budgeted the same way, so their rejected verdicts vanish the same way.
793
+ const sliceReviewArchive = await archiveReviewAttempt(runDir, row.review_ref);
794
+ return emit(flags, { ...mergedPayload(runId, sliceId, row), review_archive: sliceReviewArchive });
795
+ },
796
+
797
+ async observe([runId, subject], flags) {
798
+ if (!subject) throw new CliError("factory observe requires <subject>");
799
+ if (!flags.worktree || !flags.base) throw new CliError("factory observe requires --worktree and --base");
800
+ if (flags.repositoryVerify && flags.testCmd !== undefined) {
801
+ throw new CliError("--repository-verify is mutually exclusive with --test-cmd");
802
+ }
803
+ if (flags.repositoryVerify && subject !== "test-verifier") {
804
+ throw new CliError("--repository-verify is valid only for test-verifier");
805
+ }
806
+ const runDir = runDirFor(flags, runId);
807
+ assertRunNotParked(runDir, "observe");
808
+ const repo = resolve(flags.repo ?? process.cwd());
809
+ const run = readRun(runDir);
810
+ const integration = flags.repositoryVerify ? requireIntegrationWorktree(repo, run, flags.worktree) : null;
811
+ const worktree = integration?.worktree ?? resolveWorktree(repo, flags.worktree);
812
+ if (!worktree) throw new CliError(`worktree '${flags.worktree}' is not inside the repository`);
813
+
814
+ let repositoryVerify = null;
815
+ if (flags.repositoryVerify) {
816
+ const expectedBase = branchPoint(run);
817
+ if (flags.base !== expectedBase) throw new CliError(`--base must equal the first seeded root slice base_ref ${expectedBase}`);
818
+ try {
819
+ repositoryVerify = readRepositoryConfig(worktree);
820
+ } catch (error) {
821
+ if (error instanceof RepositoryConfigError) throw new CliError(error.message);
822
+ throw error;
823
+ }
824
+ }
825
+
826
+ let claim = null;
827
+ if (flags.claim) {
828
+ // Instruction at the moment of failure, not enforcement: an unreadable claim already refuses, so this
829
+ // changes only what the operator is told. A driver that passed the builder's report inline got an
830
+ // ENOENT whose "path" was the whole JSON document, which reads as a missing file rather than a wrong
831
+ // argument -- and it discarded a slice that had already committed and observed green.
832
+ if (/^\s*[{[]/u.test(flags.claim)) {
833
+ throw new CliError("--claim expects a path to a JSON file holding the builder's report, not the report itself");
834
+ }
835
+ try {
836
+ claim = JSON.parse(readFileSync(resolve(repo, flags.claim), "utf8"));
837
+ } catch (error) {
838
+ throw new CliError(`could not read --claim: ${error.message}`);
839
+ }
840
+ }
841
+
842
+ // Whether this subject may be review-ready without an observed test run is read from
843
+ // the ratified plan, not supplied. `--skip-tests-reason` was the flag this replaces:
844
+ // it let the orchestrator write its own exemption at the moment of observation, and
845
+ // any nonempty string was accepted, so "no tests needed" was a valid reason to ship
846
+ // untested code. An empty test_plan is the same exemption, decided at Gate 2 by the
847
+ // human who owns that call.
848
+ //
849
+ // A subject with no slice row - test-verifier, an agent step - has no ratified
850
+ // waiver and so has none: its tests must be observed.
851
+ const slice = run.slices.find((entry) => entry.id === subject);
852
+ if (slice && flags.testCmd !== undefined
853
+ && !slice.test_plan.some((entry) => entry === flags.testCmd)) {
854
+ throw new CliError(
855
+ `test command for slice '${subject}' must exactly match one ratified test_plan entry; `
856
+ + `expected ${JSON.stringify(slice.test_plan)}; received ${JSON.stringify(flags.testCmd)}`,
857
+ );
858
+ }
859
+ const skipReason = slice && slice.test_plan.length === 0
860
+ ? `test_plan for '${subject}' was approved empty at slices-seed`
861
+ : null;
862
+
863
+ const { evidence, ancestry } = await writeObservedEvidence({
864
+ runDir, runId, subject,
865
+ attempt: flags.attempt === undefined ? 1 : integer(flags.attempt, 1, "--attempt"),
866
+ branch: flags.repositoryVerify ? run.branch : flags.branch ?? null,
867
+ baseRef: flags.base, worktree, status: flags.status ?? "completed",
868
+ blockedReason: flags.blockedReason ?? null, claim,
869
+ testCommand: flags.repositoryVerify ? repositoryVerify.command : flags.testCmd ? flags.testCmd.split(" ").filter(Boolean) : null,
870
+ skipReason, shellCommand: flags.repositoryVerify === true,
871
+ testTimeoutMs: flags.repositoryVerify ? repositoryVerify.timeoutMs : undefined,
872
+ });
873
+ return emit(flags, {
874
+ run_id: runId, subject, evidence_ref: evidenceRef(subject),
875
+ review_ready: evidence.review_ready, files_changed: evidence.files_changed.length,
876
+ tests: evidence.tests.observed ? `exit ${evidence.tests.exit}` : `skipped: ${evidence.tests.skipped_reason}`,
877
+ ancestry, mismatches: evidence.claim_reconciliation.mismatches.map((entry) => entry.field),
878
+ });
879
+ },
880
+ async init(positional, flags) {
881
+ return dispatchInit(positional, flags);
882
+ },
883
+
884
+ status([runId], flags) {
885
+ const runDir = runDirFor(flags, runId);
886
+ const observed = readRunUnchecked(runDir);
887
+ if (!observed.ok) return emit(flags, { run_id: runId, valid: false, sandbox_path: resolve(flags.repo ?? process.cwd()), error: observed.error });
888
+ let run;
889
+ try {
890
+ run = readRun(runDir);
891
+ } catch (error) {
892
+ // A record that exists but does not validate is reported, not hidden: the
893
+ // operator needs to see the invalid state, not an absence.
894
+ return emit(flags, { run_id: runId, valid: false, sandbox_path: resolve(flags.repo ?? process.cwd()), error: error.message });
895
+ }
896
+ const lock = inspectSessionLock(runDir);
897
+ return emit(flags, {
898
+ run_id: run.run_id,
899
+ issue_key: run.issue_key ?? null,
900
+ valid: true, sandbox_path: resolve(flags.repo ?? process.cwd()),
901
+ status: run.status,
902
+ mode: run.mode,
903
+ branch: run.branch,
904
+ pr_base: run.pr_base ?? null,
905
+ pr_draft: run.pr_draft ?? true,
906
+ lock: lock.state, dead_lock: run.status === "running" && lock.state === "stale",
907
+ lock_session: lock.owner?.session ?? null,
908
+ gates: Object.fromEntries(GATE_NAMES.filter((name) => run.gates[name]).map((name) => [name, run.gates[name].status])),
909
+ steps: run.steps.map((step) => `${step.agent}:${step.status}(${step.attempts})`),
910
+ slices: run.slices.map((slice) => `${slice.id}:${slice.status}(${slice.attempts})`),
911
+ validator: run.validator?.verdict ?? null,
912
+ pr_url: run.pr_url,
913
+ terminal_result: run.terminal_result,
914
+ next: nextAction(run),
915
+ });
916
+ },
917
+
918
+ async lock([runId, action], flags) {
919
+ const runDir = runDirFor(flags, runId);
920
+ const ttlMs = flags.ttlMs === undefined ? undefined : integer(flags.ttlMs, undefined, "--ttl-ms");
921
+ const now = flags.now ? Date.parse(flags.now) : undefined;
922
+ try {
923
+ if (action === "claim" || action === "steal") {
924
+ const owner = await claimSessionLock(runDir, {
925
+ session: flags.session, runId, branch: flags.branch, now, ttlMs, force: action === "steal",
926
+ });
927
+ return emit(flags, { run_id: runId, action, session: owner.session, stolen_from: owner.stolen_from?.session ?? null });
928
+ }
929
+ if (action === "release") {
930
+ const released = await releaseSessionLock(runDir, { session: flags.session });
931
+ return emit(flags, { run_id: runId, action, ...released });
932
+ }
933
+ // `inspect` was here and is gone: `factory status` already reports the lock state
934
+ // and its owning session, so this was a second way to ask one question.
935
+ } catch (error) {
936
+ if (error instanceof SessionLockHeldError) {
937
+ throw new CliError(`${error.message}\n resume with --session ${error.owner.session}, or take it with 'lock ${runId} steal'`);
938
+ }
939
+ throw error;
940
+ }
941
+ throw new CliError("factory lock requires <claim|steal|release>");
942
+ },
943
+
944
+ async heartbeat([runId], flags) {
945
+ const runDir = runDirFor(flags, runId);
946
+ const owner = await refreshSessionLock(runDir, {
947
+ session: flags.session,
948
+ now: flags.now ? Date.parse(flags.now) : undefined,
949
+ });
950
+ return emit(flags, { run_id: runId, heartbeat_at: owner.heartbeat_at });
951
+ },
952
+
953
+ async gate([runId, name, decision], flags) {
954
+ if (!GATE_NAMES.includes(name)) throw new CliError(`gate must be one of ${GATE_NAMES.join(" | ")}`);
955
+ if (!GATE_STATUSES.includes(decision)) throw new CliError(`decision must be one of ${GATE_STATUSES.join(" | ")}`);
956
+ const runDir = runDirFor(flags, runId);
957
+ assertRunNotParked(runDir, "gate");
958
+ const repo = resolve(flags.repo ?? process.cwd());
959
+ const at = stamp(flags);
960
+
961
+ // Gate 3's approval is what authorizes publication, and in the skill's flow it is the
962
+ // last transition before the branch is pushed and the PR is created. Readiness was
963
+ // checked only in `factory pr`, which runs after both of those effects - it could
964
+ // report a bad publication but not prevent one. The gates contract refuses a pre_pr
965
+ // approval that arrives with no observer registered, so this cannot go quiet.
966
+ const reobservers = new Map();
967
+ if (name === "pre_pr" && decision === "approved") {
968
+ reobservers.set("gates", async ({ nextState }) => {
969
+ assertPublicationReady({
970
+ runDir, state: nextState, runId, repo,
971
+ observeHead: () => integrationHead(repo, nextState).commit,
972
+ });
973
+ });
974
+ }
975
+
976
+ const next = await transition(runDir, {
977
+ participants: [{ familyId: "gates", mode: decision === "pending" ? "open" : "decide" }],
978
+ reobservers,
979
+ apply: (state) => ({
980
+ ...state,
981
+ updated_at: at,
982
+ gates: {
983
+ ...state.gates,
984
+ [name]: {
985
+ status: decision,
986
+ at: decision === "pending" ? null : at,
987
+ artifact: flags.artifact ?? state.gates[name]?.artifact ?? null,
988
+ },
989
+ },
990
+ ...(name === "brief" ? { plan_digest: briefDigestFor(decision, state, runDir) } : {}),
991
+ }),
992
+ });
993
+ return emit(flags, { run_id: runId, gate: name, status: next.gates[name].status, at: next.gates[name].at });
994
+ },
995
+
996
+ async step([runId, agent, status], flags) {
997
+ if (!agent) throw new CliError("factory step requires <agent>");
998
+ if (!STEP_STATUSES.includes(status)) throw new CliError(`status must be one of ${STEP_STATUSES.join(" | ")}`);
999
+ const runDir = runDirFor(flags, runId);
1000
+ assertRunNotParked(runDir, "step");
1001
+ const at = stamp(flags);
1002
+ const next = await transition(runDir, {
1003
+ participants: [{ familyId: "steps", mode: "record" }],
1004
+ apply: (state) => {
1005
+ const existing = state.steps.find((step) => step.agent === agent);
1006
+ const attempts = flags.attempts === undefined
1007
+ ? existing?.attempts ?? 1
1008
+ : integer(flags.attempts, 1, "--attempts");
1009
+ const row = {
1010
+ agent,
1011
+ status,
1012
+ attempts,
1013
+ review_ref: flags.reviewRef ?? existing?.review_ref ?? null,
1014
+ evidence_ref: flags.evidenceRef ?? existing?.evidence_ref ?? null,
1015
+ };
1016
+ return {
1017
+ ...state,
1018
+ updated_at: at,
1019
+ steps: existing
1020
+ ? state.steps.map((step) => (step.agent === agent ? row : step))
1021
+ : [...state.steps, row],
1022
+ };
1023
+ },
1024
+ });
1025
+ const row = next.steps.find((step) => step.agent === agent);
1026
+ // Snapshot before the next attempt overwrites the record. Reported so a failed archive is
1027
+ // visible rather than silent -- null here means this verdict's reasoning was not kept.
1028
+ const reviewArchive = await archiveReviewAttempt(runDir, row.review_ref);
1029
+ return emit(flags, {
1030
+ run_id: runId, agent, status: row.status, attempts: row.attempts, review_archive: reviewArchive,
1031
+ });
1032
+ },
1033
+
1034
+ async terminal([runId, status], flags) {
1035
+ if (!TERMINAL_STATUSES.includes(status)) throw new CliError(`status must be one of ${TERMINAL_STATUSES.join(" | ")}`);
1036
+ if (!flags.reason) throw new CliError("factory terminal requires --reason");
1037
+ const runDir = runDirFor(flags, runId);
1038
+ assertRunNotParked(runDir, "terminal");
1039
+ const at = stamp(flags);
1040
+ const next = await transition(runDir, {
1041
+ participants: [{ familyId: "envelope", mode: "terminalize" }],
1042
+ apply: (state) => ({
1043
+ ...state,
1044
+ updated_at: at,
1045
+ status,
1046
+ terminal_result: { status, reason: flags.reason },
1047
+ }),
1048
+ });
1049
+ return emit(flags, { run_id: runId, status: next.status, reason: next.terminal_result.reason });
1050
+ },
1051
+
1052
+ async resume(positional, flags) {
1053
+ if (positional.length !== 1) throw new CliError("factory resume requires exactly one <run-id>");
1054
+ const [runId] = positional;
1055
+ const runDir = runDirFor(flags, runId);
1056
+ const boundRunBytes = readFileSync(join(runDir, "run.json"));
1057
+ const current = validateRun(JSON.parse(boundRunBytes.toString("utf8")));
1058
+ if (current.status !== "needs-human") {
1059
+ throw new CliError(`factory resume requires current status needs-human; found '${current.status}'`);
1060
+ }
1061
+ // Ownership is proven here and nowhere else in this command's family. Every other mutating
1062
+ // command advances a run whose driver already holds the lock; resume is the handoff itself --
1063
+ // the moment a new driver picks up a run nobody is driving. Two drivers resuming the same
1064
+ // parked run would both believe they own it, which is the single-writer invariant the lock
1065
+ // exists for. So the caller must already hold a fresh lock: claim, then verify, then resume.
1066
+ if (!flags.session) throw new CliError("factory resume requires --session <session-id>");
1067
+ const boundOwner = assertFreshSessionOwner(runDir, runId, flags.session, "resume");
1068
+ const at = stamp(flags);
1069
+ if (Date.parse(at) <= Date.parse(current.updated_at)) throw new CliError("resume-needs-human must move updated_at forwards");
1070
+ const repo = resolve(flags.repo ?? process.cwd());
1071
+ const config = readRepositoryConfig(repo, { optional: true });
1072
+ const outcome = config?.bootstrapCommand ? bootstrapOutcome(repo, config, "resume") : null;
1073
+ const currentBytes = outcome ? readFileSync(join(runDir, "run.json")) : boundRunBytes;
1074
+ if (outcome && !currentBytes.equals(boundRunBytes)) {
1075
+ try { validateRun(JSON.parse(currentBytes.toString("utf8"))); } catch {
1076
+ throw new CliError("factory resume bootstrap refused: current run state cannot be qualified because run.json bytes changed while bootstrap ran");
1077
+ }
1078
+ throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
1079
+ }
1080
+ if (outcome && !sameSessionOwner(runDir, boundOwner)) {
1081
+ throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
1082
+ }
1083
+ const success = outcome?.refusal == null;
1084
+ const assertBinding = ({ state }) => {
1085
+ if (!isDeepStrictEqual(state, current)) throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
1086
+ if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
1087
+ };
1088
+ const next = await transition(runDir, {
1089
+ participants: [{ familyId: "envelope", mode: success ? "resume-needs-human" : "record-bootstrap" }],
1090
+ ...(outcome ? { reobservers: new Map([["envelope", assertBinding]]), finalGuard: ({ state }) => {
1091
+ if (!readFileSync(join(runDir, "run.json")).equals(boundRunBytes) || !isDeepStrictEqual(state, current)) {
1092
+ throw new CliError("factory resume bootstrap refused: run.json bytes changed while bootstrap ran; current state was preserved");
1093
+ }
1094
+ if (!sameSessionOwner(runDir, boundOwner)) throw new CliError("factory resume bootstrap refused: factory.lock is absent, stale, or no longer names the same owner; current state and owner were preserved");
1095
+ } } : {}),
1096
+ apply: (state) => ({ ...state, ...(success ? { status: "running" } : {}), updated_at: at,
1097
+ ...(outcome ? { bootstrap_command: config.bootstrapCommand, bootstrap_exit: outcome.exit } : {}) }),
1098
+ });
1099
+ if (outcome?.refusal) throw new CliError(`${outcome.refusal}; run remains needs-human and its historical terminal result is preserved`);
1100
+ return emit(flags, {
1101
+ run_id: runId, status: next.status, terminal_result: next.terminal_result, next: nextAction(next),
1102
+ });
1103
+ },
1104
+ };
1105
+
1106
+
1107
+ function exactOid(result) {
1108
+ return result?.status === 0 && /^[0-9a-f]{40}\n$/u.test(result.stdout) ? result.stdout.slice(0, -1) : null;
1109
+ }
1110
+
1111
+ function checkBranchName(repository, value, runGit) {
1112
+ return runGit(repository, ["check-ref-format", "--branch", value])?.status === 0;
1113
+ }
1114
+
1115
+ function exactRefState(repository, ref, runGit, description, retained = false) {
1116
+ const result = runGit(repository, ["show-ref", "--verify", "--quiet", ref]);
1117
+ if (result?.status === 0) return "present";
1118
+ if (result?.status === 1) return "absent";
1119
+ const aftermath = retained ? "; sandbox was retained; run.json is absent" : "";
1120
+ throw new CliError(`could not observe ${description} '${ref}' in repository '${repository}'${aftermath}`);
1121
+ }
1122
+
1123
+ function resolveExplicitSeed(sandboxPath, base, runGit) {
1124
+ const local = `refs/heads/${base}`;
1125
+ const remote = `refs/remotes/origin/${base}`;
1126
+ // This is enforcement: classifying both qualified refs before either peel prevents a
1127
+ // successful local peel from hiding an observation failure on the remote candidate.
1128
+ const states = new Map([
1129
+ [local, exactRefState(sandboxPath, local, runGit, "PR base ref", true)],
1130
+ [remote, exactRefState(sandboxPath, remote, runGit, "PR base ref", true)],
1131
+ ]);
1132
+ const resolveRef = (ref) => {
1133
+ if (states.get(ref) === "absent") return null;
1134
+ const result = runGit(sandboxPath, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
1135
+ if (!Number.isInteger(result?.status)) {
1136
+ throw new CliError(`could not observe commit for PR base ref '${ref}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1137
+ }
1138
+ const oid = exactOid(result);
1139
+ if (!oid) throw new CliError(`PR base ref '${ref}' could not be peeled to one commit in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1140
+ return oid;
1141
+ };
1142
+ const localOid = resolveRef(local);
1143
+ const remoteOid = resolveRef(remote);
1144
+ const candidates = `'${local}' or '${remote}'`;
1145
+ if (!localOid && !remoteOid) throw new CliError(`PR base '${base}' could not be resolved from ${candidates} in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1146
+ if (localOid && remoteOid && localOid !== remoteOid) throw new CliError(`PR base '${base}' resolves to different commits at '${local}' and '${remote}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1147
+ return localOid ?? remoteOid;
1148
+ }
1149
+
1150
+ function proveInitBranch({ operatorRoot, sandboxPath, worktree, branch, seed, runGit, resolvePath }) {
1151
+ const ref = `refs/heads/${branch}`;
1152
+ if (exactRefState(operatorRoot, ref, runGit, "feature branch ref", true) === "present") {
1153
+ throw new CliError(`feature branch '${branch}' appeared at '${ref}' in operator repository '${operatorRoot}' while sandbox '${sandboxPath}' was initialized; sandbox was retained; run.json is absent`);
1154
+ }
1155
+ const symbolic = runGit(worktree, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
1156
+ if (symbolic?.status !== 0 || symbolic.stdout !== `${branch}\n`) throw new CliError(`sandbox feature branch '${branch}' is not the exact symbolic HEAD in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1157
+ const branchOid = exactOid(runGit(worktree, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]));
1158
+ const headOid = exactOid(runGit(worktree, ["rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"]));
1159
+ if (branchOid !== seed || headOid !== seed) throw new CliError(`sandbox feature branch '${branch}' or worktree HEAD moved from seed '${seed}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1160
+ const logResult = runGit(worktree, ["rev-parse", "--git-path", `logs/${ref}`]);
1161
+ if (logResult?.status !== 0 || !logResult.stdout.endsWith("\n") || logResult.stdout.slice(0, -1).includes("\n")) throw new CliError(`could not observe creation reflog for feature branch '${branch}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1162
+ let raw;
1163
+ try {
1164
+ raw = readFileSync(resolvePath(worktree, logResult.stdout.slice(0, -1)), "utf8");
1165
+ } catch {
1166
+ throw new CliError(`could not observe creation reflog for feature branch '${branch}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1167
+ }
1168
+ const match = raw.match(/^([0-9a-f]{40}) ([0-9a-f]{40}) .+\tbranch: Created from ([0-9a-f]{40})\n$/u);
1169
+ if (!match || match[1] !== "0".repeat(40) || match[2] !== seed || match[3] !== seed) throw new CliError(`feature branch '${branch}' does not have exact one-line creation provenance from seed '${seed}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1170
+ const ancestry = runGit(worktree, ["merge-base", "--is-ancestor", seed, ref]);
1171
+ if (ancestry?.status !== 0) throw new CliError(`feature branch seed '${seed}' is not a proven ancestor of '${ref}' in sandbox '${sandboxPath}'; sandbox was retained; run.json is absent`);
1172
+ }
1173
+
1174
+ // Enforcement, following the #277 seed-guard precedent: an unignored control plane cannot complete a factory run.
1175
+ function assertInitPathsIgnored({ operatorRoot, sandboxPath, runId, runGit, resolvePath, joinPath }) {
1176
+ const rootIgnore = joinPath(operatorRoot, ".gitignore"), tracked = runGit(operatorRoot, ["ls-files", "--error-unmatch", "--", ".gitignore"]);
1177
+ const requirements = [[`${CONTROL_PLANE}/${runId}/run.json`, `${CONTROL_PLANE}/`], [".factory-sandboxes/", "/.factory-sandboxes/"], [`.factory-sandboxes/${runId}/${CONTROL_PLANE}/${runId}/run.json`, "/.factory-sandboxes/"]];
1178
+ for (const [probe, requiredLine] of requirements) {
1179
+ const observed = runGit(operatorRoot, ["check-ignore", "-v", "--no-index", "--", probe]), output = observed?.status === 0 && typeof observed.stdout === "string" ? observed.stdout : "";
1180
+ const match = /^(.+):([1-9][0-9]*):(.+)\t(.+)\n$/u.exec(output);
1181
+ const qualifies = tracked?.status === 0 && tracked.stdout === ".gitignore\n" && match?.[4] === probe && !match[3].includes("\n") && resolvePath(operatorRoot, match[1]) === rootIgnore;
1182
+ if (!qualifies) throw new CliError(`factory init requires '${probe}' to be ignored by tracked root '.gitignore' in operator repository '${operatorRoot}'; add exactly '${requiredLine}' to '${rootIgnore}'; sandbox path '${sandboxPath}' was not created`);
1183
+ }
1184
+ }
1185
+ export async function dispatchInit(positional, flags, operations = INIT_OPERATIONS) {
1186
+ const candidate = preflightInit(positional, flags);
1187
+ const {
1188
+ cwd, resolvePath, joinPath, realpath, lstat, mkdir, readdir,
1189
+ runGit, prove, publish,
1190
+ } = operations;
1191
+ const dispatchInitPublication = publish;
1192
+ const runId = candidate.run_id;
1193
+ const operatorInput = resolvePath(flags.repo ?? cwd());
1194
+ let operatorRoot;
1195
+ try {
1196
+ operatorRoot = realpath(operatorInput);
1197
+ } catch {
1198
+ const S = joinPath(operatorInput, ".factory-sandboxes", runId);
1199
+ throw new CliError(`operator repository is not observable; sandbox path '${S}' was not created`);
1200
+ }
1201
+ const C = joinPath(operatorRoot, ".factory-sandboxes");
1202
+ const S = joinPath(C, runId);
1203
+ const runDir = joinPath(S, CONTROL_PLANE, runId);
1204
+ const legacyManifest = joinPath(operatorRoot, CONTROL_PLANE, runId, "run.json");
1205
+ const sandboxManifest = joinPath(runDir, "run.json");
1206
+
1207
+ let top;
1208
+ try {
1209
+ const observed = runGit(operatorRoot, ["rev-parse", "--show-toplevel"]);
1210
+ top = observed.ok && observed.stdout.trim() ? realpath(resolvePath(operatorRoot, observed.stdout.trim())) : null;
1211
+ } catch {
1212
+ top = null;
1213
+ }
1214
+ if (top !== operatorRoot) throw new CliError(`--repo must name the canonical operator repository root; sandbox path '${S}' was not created`);
1215
+
1216
+ assertInitPathsIgnored({ operatorRoot, sandboxPath: S, runId, runGit, resolvePath, joinPath });
1217
+ let legacyState;
1218
+ let containerState;
1219
+ let sandboxState = { kind: "absent" };
1220
+ let sandboxManifestState = "absent";
1221
+ try {
1222
+ legacyState = manifestPresence(legacyManifest, { lstat });
1223
+ containerState = directoryState(C, { lstat, realpath });
1224
+ if (containerState.kind === "directory") {
1225
+ sandboxState = directoryState(S, { lstat, realpath });
1226
+ if (sandboxState.kind === "directory") sandboxManifestState = manifestPresence(sandboxManifest, { lstat });
1227
+ }
1228
+ } catch (error) {
1229
+ throw new CliError(`could not inspect destination policy for sandbox '${S}'`, { cause: error });
1230
+ }
1231
+ if (legacyState === "present" && sandboxManifestState === "present") {
1232
+ throw new CliError(`ambiguous run '${runId}': manifests exist at '${legacyManifest}' and '${sandboxManifest}'; inspect status with --repo '${operatorRoot}' and --repo '${S}'`);
1233
+ }
1234
+ if (containerState.kind === "unsafe") {
1235
+ const legacy = legacyState === "present" ? `; run status/resume for '${legacyManifest}' with --repo '${operatorRoot}'` : "";
1236
+ throw new CliError(`sandbox container '${C}' is ${containerState.type}; sandbox path '${S}' was not changed${legacy}`);
1237
+ }
1238
+ if (sandboxState.kind === "unsafe") {
1239
+ const legacy = legacyState === "present" ? `; run status/resume for '${legacyManifest}' with --repo '${operatorRoot}'` : "";
1240
+ throw new CliError(`sandbox destination '${S}' is ${sandboxState.type}; it was not reused, changed, or deleted${legacy}`);
1241
+ }
1242
+ if (legacyState === "present") throw new CliError(`run '${runId}' already exists at '${legacyManifest}'; run status/resume with --repo '${operatorRoot}'; sandbox path '${S}' was not created`);
1243
+ if (sandboxManifestState === "present") throw new CliError(`run '${runId}' already exists at '${sandboxManifest}'; run status/resume with --repo '${S}'`);
1244
+ if (sandboxState.kind === "directory") {
1245
+ const detail = sandboxManifestState === "blocked" ? " with a manifest path blocked by a non-directory component" : " without a manifest";
1246
+ throw new CliError(`sandbox destination '${S}' already exists${detail}; it was not reused, changed, or deleted`);
1247
+ }
1248
+
1249
+ if (!checkBranchName(operatorRoot, candidate.branch, runGit)) {
1250
+ throw new CliError(`feature branch '${candidate.branch}' is not a valid branch name; sandbox path '${S}' was not created`);
1251
+ }
1252
+ if (candidate.pr_base !== null && !checkBranchName(operatorRoot, candidate.pr_base, runGit)) {
1253
+ throw new CliError(`PR base '${candidate.pr_base}' is not a valid branch name; sandbox path '${S}' was not created`);
1254
+ }
1255
+ const featureRef = `refs/heads/${candidate.branch}`;
1256
+ if (exactRefState(operatorRoot, featureRef, runGit, "feature branch ref") === "present") {
1257
+ throw new CliError(`feature branch '${candidate.branch}' already exists at '${featureRef}' in operator repository '${operatorRoot}'; restore the operator checkout to 'main', remove the colliding '${candidate.branch}' ref, and retry; sandbox path '${S}' was not created`);
1258
+ }
1259
+ if (candidate.pr_base !== null) {
1260
+ const baseRef = `refs/heads/${candidate.pr_base}`;
1261
+ if (exactRefState(operatorRoot, baseRef, runGit, "PR base ref") === "absent") {
1262
+ throw new CliError(`PR base '${candidate.pr_base}' does not name local ref '${baseRef}' in operator repository '${operatorRoot}'; sandbox path '${S}' was not created`);
1263
+ }
1264
+ }
1265
+
1266
+ try {
1267
+ const observedContainer = directoryState(C, { lstat, realpath });
1268
+ if (observedContainer.kind === "absent") mkdir(C);
1269
+ else if (observedContainer.kind !== "directory") throw new Error(`unsafe sandbox container '${C}'`);
1270
+ exactDirectory(C, { lstat, realpath });
1271
+ mkdir(S);
1272
+ exactDirectory(S, { lstat, realpath });
1273
+ if (readdir(S).length !== 0) throw new Error("reserved destination is not empty");
1274
+ } catch (error) {
1275
+ throw new CliError(`could not reserve empty sandbox '${S}'; existing state was preserved`, { cause: error });
1276
+ }
1277
+
1278
+ const cloned = runGit(operatorRoot, ["clone", "--local", "--", operatorRoot, S]);
1279
+ if (!cloned.ok) {
1280
+ let manifest;
1281
+ try {
1282
+ const state = manifestPresence(sandboxManifest, { lstat });
1283
+ manifest = state === "present" ? "present" : state === "absent" ? "absent" : "unobservable";
1284
+ } catch {
1285
+ manifest = "unobservable";
1286
+ }
1287
+ throw new CliError(`git clone failed for sandbox '${S}'; run.json is ${manifest}; sandbox was retained`);
1288
+ }
1289
+
1290
+ let proof;
1291
+ try {
1292
+ proof = prove({ operatorRoot, sandboxPath: S, runId, worktree: candidate.worktree });
1293
+ } catch (error) {
1294
+ throw new CliError(`physical containment could not be proved for sandbox '${S}'; sandbox was retained`, { cause: error });
1295
+ }
1296
+
1297
+ let prBase = candidate.pr_base;
1298
+ let seed;
1299
+ if (prBase === null) {
1300
+ const observed = runGit(proof.configuredWorktree, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
1301
+ prBase = observed.ok ? observed.stdout.trim() : "";
1302
+ if (!prBase) throw new CliError(`could not observe a symbolic branch in PR base worktree '${candidate.worktree}' for sandbox '${S}'; pass --pr-base <branch> explicitly; sandbox was retained`);
1303
+ seed = exactOid(runGit(proof.configuredWorktree, ["rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"]));
1304
+ if (!seed) throw new CliError(`could not observe sandbox HEAD seed in sandbox '${S}'; sandbox was retained; run.json is absent`);
1305
+ } else {
1306
+ seed = resolveExplicitSeed(S, prBase, runGit);
1307
+ }
1308
+ const switched = runGit(proof.configuredWorktree, ["switch", "--no-track", "-c", candidate.branch, seed]);
1309
+ if (switched?.status !== 0) throw new CliError(`could not create feature branch '${candidate.branch}' from seed '${seed}' in sandbox '${S}'; sandbox was retained; run.json is absent`);
1310
+ const proveBranch = () => proveInitBranch({ operatorRoot, sandboxPath: S, worktree: proof.configuredWorktree, branch: candidate.branch, seed, runGit, resolvePath });
1311
+ // False-green enforcement: bootstrap is an arbitrary repository-declared command, so the physical
1312
+ // proof must be repeated after it runs and again immediately before publication. Branch, ref and
1313
+ // reflog evidence is all readable through a `.git` that has been relocated or rebound outside the
1314
+ // sandbox, so logical provenance alone cannot see the escape -- and publishing `run.json` for a
1315
+ // repository whose Git administration left the sandbox is exactly the green this proof prevents.
1316
+ const proveContainedBranch = () => {
1317
+ try {
1318
+ prove({ operatorRoot, sandboxPath: S, runId, worktree: candidate.worktree });
1319
+ } catch (error) {
1320
+ throw new CliError(`physical containment could not be re-proved for sandbox '${S}'; sandbox was retained; run.json is absent`, { cause: error });
1321
+ }
1322
+ proveBranch();
1323
+ };
1324
+ proveBranch();
1325
+ const config = readRepositoryConfig(S, { optional: true });
1326
+ let bootstrapEvidence = {};
1327
+ if (config?.bootstrapCommand) {
1328
+ const outcome = bootstrapOutcome(S, config, "init");
1329
+ if (outcome.refusal) throw new CliError(`${outcome.refusal}; sandbox '${S}' was retained; run.json is absent`);
1330
+ bootstrapEvidence = { bootstrap_command: config.bootstrapCommand, bootstrap_exit: outcome.exit };
1331
+ }
1332
+ proveContainedBranch();
1333
+ let run;
1334
+ try {
1335
+ run = validateRun({ ...candidate, pr_base: prBase, pr_draft: config?.prDraft ?? true, ...bootstrapEvidence });
1336
+ } catch (error) {
1337
+ throw new CliError(`final manifest validation failed for sandbox '${S}'; sandbox was retained`, { cause: error });
1338
+ }
1339
+ const { observedRun } = await dispatchInitPublication({ runDir, sandboxPath: S, candidate: run, finalGuard: proveContainedBranch });
1340
+ return emit(flags, {
1341
+ run_id: observedRun.run_id, run_dir: runDir, sandbox_path: proof.sandboxPath,
1342
+ branch: observedRun.branch, worktree: observedRun.worktree, pr_base: observedRun.pr_base,
1343
+ status: observedRun.status, mode: observedRun.mode,
1344
+ });
1345
+ }
1346
+
1347
+ function preflightInit(positional, flags) {
1348
+ const refusal = (message, cause) => new CliError(`${message}; no sandbox path was derived or created`, cause ? { cause } : undefined);
1349
+ if (positional.length !== 1) throw refusal("factory init requires exactly one <run-id>");
1350
+ if (flags.repo !== undefined && (typeof flags.repo !== "string" || !flags.repo.trim())) throw refusal("--repo must be a non-empty string");
1351
+ const runId = positional[0];
1352
+ try {
1353
+ const at = stamp(flags);
1354
+ return validateRun({
1355
+ version: SCHEMA_VERSION,
1356
+ run_id: runId,
1357
+ issue_key: flags.issue ?? null,
1358
+ branch: flags.branch ?? `feature/${runId}`,
1359
+ worktree: flags.worktree ?? ".",
1360
+ pr_base: flags.prBase ?? null,
1361
+ created_at: at,
1362
+ updated_at: at,
1363
+ status: "running",
1364
+ mode: flags.mode ?? "interactive",
1365
+ max_parallel_slices: integer(flags.maxParallelSlices, 3, "--max-parallel-slices"),
1366
+ max_retries: integer(flags.maxRetries, 3, "--max-retries"),
1367
+ gates: {},
1368
+ steps: [],
1369
+ slices: [],
1370
+ validator: null,
1371
+ terminal_result: null,
1372
+ pr_url: null,
1373
+ plan_digest: null,
1374
+ });
1375
+ } catch (error) {
1376
+ throw refusal(error.message, error);
1377
+ }
1378
+ }
1379
+
1380
+ function manifestPresence(path, { lstat }) {
1381
+ try {
1382
+ lstat(path);
1383
+ return "present";
1384
+ } catch (error) {
1385
+ if (error?.code === "ENOENT") return "absent";
1386
+ if (error?.code === "ENOTDIR") return "blocked";
1387
+ throw error;
1388
+ }
1389
+ }
1390
+
1391
+ function directoryState(path, { lstat, realpath }) {
1392
+ let stats;
1393
+ try {
1394
+ stats = lstat(path);
1395
+ } catch (error) {
1396
+ if (error?.code === "ENOENT") return { kind: "absent" };
1397
+ throw error;
1398
+ }
1399
+ if (stats.isSymbolicLink()) return { kind: "unsafe", type: "a symbolic link" };
1400
+ if (!stats.isDirectory()) return { kind: "unsafe", type: stats.isFile() ? "a regular file" : "an unsafe filesystem entry" };
1401
+ if (realpath(path) !== path) return { kind: "unsafe", type: "a non-canonical directory" };
1402
+ return { kind: "directory" };
1403
+ }
1404
+
1405
+ function exactDirectory(path, operations) {
1406
+ const state = directoryState(path, operations);
1407
+ if (state.kind !== "directory") throw new Error(`unsafe directory '${path}'`);
1408
+ }
1409
+
1410
+ function integer(value, fallback, flag) {
1411
+ if (value === undefined) return fallback;
1412
+ const parsed = Number(value);
1413
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new CliError(`${flag} must be a positive integer`);
1414
+ return parsed;
1415
+ }
1416
+
1417
+ function stamp(flags) {
1418
+ const at = flags.now !== undefined ? Date.parse(flags.now) : Date.now();
1419
+ if (!Number.isFinite(at)) throw new CliError("--now must be an ISO timestamp");
1420
+ return new Date(at).toISOString();
1421
+ }
1422
+
1423
+ function emit(flags, payload) {
1424
+ if (flags.json) {
1425
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1426
+ } else {
1427
+ for (const [name, value] of Object.entries(payload)) {
1428
+ if (value === null || value === undefined) continue;
1429
+ process.stdout.write(`${name}: ${typeof value === "object" ? JSON.stringify(value) : value}\n`);
1430
+ }
1431
+ }
1432
+ return payload;
1433
+ }
1434
+
1435
+ function usage() {
1436
+ process.stdout.write(`factory — durable control plane for /feature runs
1437
+
1438
+ factory init <run-id> [--branch B=feature/<run-id>] [--worktree W=.] [--pr-base TARGET] [--issue KEY] [--mode interactive|headless|autonomous]
1439
+ factory status <run-id> [--json]
1440
+ factory amend-paths <run-id> <slice-id> --add PATH [--add PATH ...] --reason TEXT --session ID [--now ISO]
1441
+ factory resume <run-id> --session ID [--now ISO]
1442
+ factory reverify-repair <run-id> <repair-record-id> [--repo PATH] [--now ISO] [--json]
1443
+ factory lock <run-id> <claim|steal|release> --session ID [--ttl-ms N]
1444
+ factory heartbeat <run-id> --session ID
1445
+ factory gate <run-id> <${GATE_NAMES.join("|")}> <${GATE_STATUSES.join("|")}> [--artifact REF]
1446
+ factory step <run-id> <agent> <${STEP_STATUSES.join("|")}> [--attempts N] [--review-ref REF] [--evidence-ref REF]
1447
+ factory validator <run-id> --report REF (verdict and head come from reviews/implementation-validator.json)
1448
+ factory terminal <run-id> <${TERMINAL_STATUSES.join("|")}> --reason TEXT
1449
+ factory effective-push <bootstrap|check> <operator-repository> <sandbox-repository>
1450
+
1451
+ State commands take [--repo PATH] and [--json]. effective-push accepts no options. Unknown options are errors.
1452
+ `);
1453
+ return null;
1454
+ }
1455
+
1456
+ // A refusal raised inside a transition reaches here wrapped by the atomic writer,
1457
+ // whose own message is generic. Printing only `error.message` would report every
1458
+ // refusal as "protected file commit failed" and hide the reason the operator needs,
1459
+ // so the cause chain is printed. This was caught by an end-to-end test rather than
1460
+ // by reading the code.
1461
+ export function describeError(error, depth = 0) {
1462
+ const lines = [];
1463
+ let current = error;
1464
+ let indent = "";
1465
+ while (current && depth < 5) {
1466
+ const message = String(current.message ?? current);
1467
+ // The wrapper adds nothing once its cause is shown; skip it rather than lead
1468
+ // with it.
1469
+ if (!(indent === "" && current.cause && message === "protected file commit failed")) {
1470
+ lines.push(`${indent}${message}`);
1471
+ indent = `${indent} `;
1472
+ }
1473
+ current = current.cause;
1474
+ depth += 1;
1475
+ }
1476
+ return lines.join("\n");
1477
+ }
1478
+
1479
+ // Invoked as a program rather than imported. Compared through realpath and pathToFileURL, not by
1480
+ // building a `file://` string by hand: `process.argv[1]` is the path as typed, while
1481
+ // `import.meta.url` is resolved, so a symlink anywhere in it makes the two differ and the CLI exits
1482
+ // 0 having done nothing. That is not exotic — on macOS every temp directory is /var -> /private/var,
1483
+ // and npm bin shims are symlinks. It was invisible to the whole suite because every test invokes the
1484
+ // CLI through an already-resolved absolute path; the packed-tarball test found it immediately.
1485
+ const invokedAsProgram = () => {
1486
+ if (!process.argv[1]) return false;
1487
+ try {
1488
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
1489
+ } catch {
1490
+ return false;
1491
+ }
1492
+ };
1493
+
1494
+ if (invokedAsProgram()) {
1495
+ run(process.argv.slice(2)).catch((error) => {
1496
+ process.stderr.write(`${describeError(error)}\n`);
1497
+ process.exitCode = 1;
1498
+ });
1499
+ }