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/LICENSE +21 -0
- package/README.md +278 -0
- package/WORKFLOW.md +2001 -0
- package/agents/backend-builder.md +102 -0
- package/agents/codebase-researcher.md +122 -0
- package/agents/design-interpreter.md +71 -0
- package/agents/frontend-builder.md +110 -0
- package/agents/implementation-validator.md +78 -0
- package/agents/spec-writer.md +95 -0
- package/agents/story-reader.md +70 -0
- package/agents/story-writer.md +62 -0
- package/agents/test-verifier.md +94 -0
- package/agents/work-decomposer.md +188 -0
- package/agents/work-reviewer.md +131 -0
- package/bin/factory.js +1499 -0
- package/bin/init-publication.js +73 -0
- package/core/atomic-write.js +135 -0
- package/core/contracts.js +394 -0
- package/core/effective-push.js +88 -0
- package/core/executable.js +29 -0
- package/core/run-lock.js +269 -0
- package/core/write-core.js +146 -0
- package/observe/index.js +366 -0
- package/observe/repair-record.js +300 -0
- package/observe/repair-reverification.js +169 -0
- package/observe/repository-config.js +56 -0
- package/observe/review.js +362 -0
- package/package.json +35 -0
- package/state/index.js +64 -0
- package/state/review-archive.js +48 -0
- package/state/schema.js +339 -0
- package/state/session-lock.js +104 -0
- package/state/transition.js +26 -0
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
|
+
}
|