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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ProtectedWriteError, writeProtectedJsonAtomic } from "../core/atomic-write.js";
|
|
5
|
+
import { validateRun } from "../state/schema.js";
|
|
6
|
+
|
|
7
|
+
const CLASSES = new Set(["absent", "unsafe", "unreadable", "invalid", "different", "exact"]);
|
|
8
|
+
const COMMITTED = new Set([
|
|
9
|
+
"protected create published target but initial temporary cleanup failed",
|
|
10
|
+
"protected create published target but temporary cleanup is indeterminate",
|
|
11
|
+
"protected target is committed but directory sync failed",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export async function dispatchInitPublication(
|
|
15
|
+
{ runDir, sandboxPath, candidate, finalGuard },
|
|
16
|
+
{ writer = writeProtectedJsonAtomic, observeTarget = observeInitTarget } = {},
|
|
17
|
+
) {
|
|
18
|
+
const intendedBytes = `${JSON.stringify(candidate, null, 2)}\n`;
|
|
19
|
+
if (finalGuard) await finalGuard();
|
|
20
|
+
let writerError = null;
|
|
21
|
+
try {
|
|
22
|
+
await writer(runDir, "run.json", candidate, { createOnly: true });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
writerError = error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let observed;
|
|
28
|
+
try {
|
|
29
|
+
observed = await observeTarget({ runDir, candidate, intendedBytes });
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`manifest state unobservable at sandbox '${sandboxPath}'`);
|
|
32
|
+
}
|
|
33
|
+
const classification = typeof observed === "string" ? observed : observed?.classification;
|
|
34
|
+
if (!CLASSES.has(classification)) throw new Error(`manifest state unobservable at sandbox '${sandboxPath}'`);
|
|
35
|
+
|
|
36
|
+
const normal = writerError === null;
|
|
37
|
+
const committed = writerError instanceof ProtectedWriteError && COMMITTED.has(writerError.message);
|
|
38
|
+
if ((normal || committed) && classification === "exact") return { observedRun: observed.observedRun };
|
|
39
|
+
|
|
40
|
+
const collision = writerError instanceof ProtectedWriteError && writerError.message === "protected create target already exists";
|
|
41
|
+
const action = collision ? `; run 'factory status ${candidate.run_id} --json --repo ${sandboxPath}' and resume` : "";
|
|
42
|
+
throw new Error(`manifest publication refused for sandbox '${sandboxPath}': observed ${classification}${action}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function observeInitTarget({ runDir, candidate, intendedBytes }) {
|
|
46
|
+
const target = join(runDir, "run.json");
|
|
47
|
+
let stats;
|
|
48
|
+
try {
|
|
49
|
+
stats = lstatSync(target);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { classification: error?.code === "ENOENT" ? "absent" : "unreadable" };
|
|
52
|
+
}
|
|
53
|
+
if (stats.isSymbolicLink() || !stats.isFile()) return { classification: "unsafe" };
|
|
54
|
+
try {
|
|
55
|
+
if (realpathSync(target) !== target || realpathSync(runDir) !== runDir) return { classification: "unsafe" };
|
|
56
|
+
} catch {
|
|
57
|
+
return { classification: "unreadable" };
|
|
58
|
+
}
|
|
59
|
+
let bytes;
|
|
60
|
+
try {
|
|
61
|
+
bytes = readFileSync(target, "utf8");
|
|
62
|
+
} catch {
|
|
63
|
+
return { classification: "unreadable" };
|
|
64
|
+
}
|
|
65
|
+
let observedRun;
|
|
66
|
+
try {
|
|
67
|
+
observedRun = validateRun(JSON.parse(bytes));
|
|
68
|
+
} catch {
|
|
69
|
+
return { classification: "invalid" };
|
|
70
|
+
}
|
|
71
|
+
if (bytes !== intendedBytes || !isDeepStrictEqual(observedRun, candidate)) return { classification: "different", observedRun };
|
|
72
|
+
return { classification: "exact", observedRun };
|
|
73
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Exclusive same-directory temp, then fsync of the file and of the directory. The directory fsync is
|
|
2
|
+
// what makes a completed publication survive power loss, and attack 9 (crash-recovery replay) rests
|
|
3
|
+
// on it. Ordinary writes recheck the target immediately before the rename, not only up front: this
|
|
4
|
+
// writes into a working tree, so a local process swapping run.json for a symlink inside that window
|
|
5
|
+
// is a real failure mode and an up-front-only check is a TOCTOU hole. Create-only writes preflight
|
|
6
|
+
// absence and publish by link. beforeCommit is the last race seam, used by CAS and create-only tests.
|
|
7
|
+
import { link as fsLink, lstat, open, rename as fsRename, unlink } from "node:fs/promises";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
10
|
+
|
|
11
|
+
export class ProtectedWriteError extends Error {
|
|
12
|
+
constructor(message, cause) {
|
|
13
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
14
|
+
this.name = "ProtectedWriteError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeProtectedJsonAtomic(rootDir, relativePath, value, options = {}) {
|
|
19
|
+
return writeProtectedFileAtomic(rootDir, relativePath, `${JSON.stringify(value, null, 2)}\n`, options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeProtectedFileAtomic(rootDir, relativePath, data, options = {}) {
|
|
23
|
+
const targetPath = resolveProtectedPath(rootDir, relativePath);
|
|
24
|
+
const parentDir = resolve(targetPath, "..");
|
|
25
|
+
const createOnly = options.createOnly === true;
|
|
26
|
+
const rename = options.fsOps?.rename ?? fsRename;
|
|
27
|
+
const link = options.fsOps?.link ?? fsLink;
|
|
28
|
+
const beforeCommit = options.hooks?.beforeCommit;
|
|
29
|
+
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8");
|
|
30
|
+
|
|
31
|
+
await assertSafeTarget(targetPath, createOnly);
|
|
32
|
+
|
|
33
|
+
const tempPath = join(parentDir, `.${randomUUID()}.tmp`);
|
|
34
|
+
let handle = null;
|
|
35
|
+
let published = false;
|
|
36
|
+
try {
|
|
37
|
+
// "wx" is O_CREAT|O_EXCL|O_WRONLY: if the name exists, fail rather than adopt
|
|
38
|
+
// a file somebody else created.
|
|
39
|
+
handle = await open(tempPath, "wx", 0o600);
|
|
40
|
+
await handle.writeFile(bytes);
|
|
41
|
+
await handle.sync();
|
|
42
|
+
await handle.close();
|
|
43
|
+
handle = null;
|
|
44
|
+
|
|
45
|
+
if (createOnly) {
|
|
46
|
+
await assertSafeTarget(targetPath, true);
|
|
47
|
+
if (typeof beforeCommit === "function") await beforeCommit();
|
|
48
|
+
try { await link(tempPath, targetPath); } catch (error) {
|
|
49
|
+
if (error?.code === "EEXIST") throw new ProtectedWriteError("protected create target already exists", error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
published = true;
|
|
53
|
+
try { await unlink(tempPath); } catch (cleanupError) {
|
|
54
|
+
try { await unlink(tempPath); } catch (retryError) {
|
|
55
|
+
if (retryError?.code !== "ENOENT") {
|
|
56
|
+
throw new ProtectedWriteError("protected create published target but temporary cleanup is indeterminate", retryError);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new ProtectedWriteError("protected create published target but initial temporary cleanup failed", cleanupError);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
if (typeof beforeCommit === "function") await beforeCommit();
|
|
63
|
+
await assertSafeTarget(targetPath, false);
|
|
64
|
+
await rename(tempPath, targetPath);
|
|
65
|
+
published = true;
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (handle) {
|
|
69
|
+
try { await handle.close(); } catch { /* the original error is the one that matters */ }
|
|
70
|
+
}
|
|
71
|
+
if (!published) {
|
|
72
|
+
try {
|
|
73
|
+
await unlink(tempPath);
|
|
74
|
+
} catch (cleanupError) {
|
|
75
|
+
if (cleanupError?.code !== "ENOENT") {
|
|
76
|
+
throw new ProtectedWriteError("protected temporary file cleanup is indeterminate", cleanupError);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw error instanceof ProtectedWriteError
|
|
81
|
+
? error
|
|
82
|
+
: new ProtectedWriteError("protected file commit failed", error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await syncDirectory(parentDir);
|
|
86
|
+
return { path: targetPath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveProtectedPath(rootDir, relativePath) {
|
|
90
|
+
if (typeof rootDir !== "string" || !rootDir.trim() || !isAbsolute(rootDir)) {
|
|
91
|
+
throw new ProtectedWriteError("protected file root is invalid");
|
|
92
|
+
}
|
|
93
|
+
if (typeof relativePath !== "string" || !relativePath.trim() || isAbsolute(relativePath)) {
|
|
94
|
+
throw new ProtectedWriteError("protected relative path is invalid");
|
|
95
|
+
}
|
|
96
|
+
const root = resolve(rootDir);
|
|
97
|
+
const targetPath = resolve(root, relativePath);
|
|
98
|
+
// Containment, not string prefixing: comparing against `${root}${sep}` refuses
|
|
99
|
+
// `/a/bc` for root `/a/b`.
|
|
100
|
+
if (targetPath === root || !targetPath.startsWith(`${root}${sep}`)) {
|
|
101
|
+
throw new ProtectedWriteError("protected relative path escapes its root");
|
|
102
|
+
}
|
|
103
|
+
return targetPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function assertSafeTarget(path, createOnly) {
|
|
107
|
+
let stats;
|
|
108
|
+
try {
|
|
109
|
+
stats = await lstat(path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Absent is the normal first-write case; anything else is unreadable state.
|
|
112
|
+
if (error?.code === "ENOENT") return;
|
|
113
|
+
throw new ProtectedWriteError("protected file target could not be inspected", error);
|
|
114
|
+
}
|
|
115
|
+
if (createOnly) throw new ProtectedWriteError("protected create target already exists");
|
|
116
|
+
if (!stats.isFile()) throw new ProtectedWriteError("protected file target has an unsafe type");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function syncDirectory(dir) {
|
|
120
|
+
let handle = null;
|
|
121
|
+
try {
|
|
122
|
+
handle = await open(dir, "r");
|
|
123
|
+
await handle.sync();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// A filesystem that refuses to fsync a directory is not a reason to fail a
|
|
126
|
+
// write that already committed: durability degrades, correctness does not.
|
|
127
|
+
if (!["EINVAL", "EPERM", "EISDIR", "EACCES", "ENOTSUP"].includes(error?.code)) {
|
|
128
|
+
throw new ProtectedWriteError("protected target is committed but directory sync failed", error);
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
if (handle) {
|
|
132
|
+
try { await handle.close(); } catch { /* nothing actionable */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Family contracts against the schema-neutral write core.
|
|
2
|
+
//
|
|
3
|
+
// Each contract owns one region of run.json: its projection, the transitions that
|
|
4
|
+
// region permits, and any re-observation it needs immediately before the atomic
|
|
5
|
+
// rename. The core knows none of this — it calls the four methods and never
|
|
6
|
+
// branches on which family it is talking to.
|
|
7
|
+
//
|
|
8
|
+
// `mode` is a static, code-owned string declared by the transition descriptor. It
|
|
9
|
+
// is never persisted, never produced by an agent, and never hashed.
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { GATE_NAMES, GATE_STATUSES, SLICE_STATUSES, STEP_STATUSES, TERMINAL_STATUSES } from "../state/schema.js";
|
|
12
|
+
|
|
13
|
+
const TERMINAL_MODES = new Set(["terminalize"]);
|
|
14
|
+
|
|
15
|
+
// The core hands each contract the observer the caller registered; it does not call it.
|
|
16
|
+
// A contract that omits `reobserve` therefore ignores that observer silently, which is
|
|
17
|
+
// twice now how a check that read as enforcement turned out to be dead. Families whose
|
|
18
|
+
// only re-observation is the caller's use this.
|
|
19
|
+
async function callRegisteredObserver({ observe, ...rest }) {
|
|
20
|
+
if (typeof observe === "function") await observe(rest);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function contract({ id, project, validateTransition, reobserve }) {
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
id,
|
|
26
|
+
project,
|
|
27
|
+
validateProjection: (projection) => {
|
|
28
|
+
if (projection === undefined) throw new Error(`${id} projection is undefined`);
|
|
29
|
+
},
|
|
30
|
+
validateTransition,
|
|
31
|
+
reobserve: reobserve ?? (async () => {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// envelope — identity, status, timestamps, limits, terminal_result
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const envelope = contract({
|
|
39
|
+
id: "envelope",
|
|
40
|
+
reobserve: callRegisteredObserver,
|
|
41
|
+
project: (state) => ({
|
|
42
|
+
run_id: state.run_id,
|
|
43
|
+
status: state.status,
|
|
44
|
+
mode: state.mode,
|
|
45
|
+
branch: state.branch,
|
|
46
|
+
worktree: state.worktree,
|
|
47
|
+
pr_base: state.pr_base,
|
|
48
|
+
pr_draft: state.pr_draft,
|
|
49
|
+
created_at: state.created_at,
|
|
50
|
+
updated_at: state.updated_at,
|
|
51
|
+
terminal_result: state.terminal_result ?? null,
|
|
52
|
+
bootstrap_command: state.bootstrap_command,
|
|
53
|
+
bootstrap_exit: state.bootstrap_exit,
|
|
54
|
+
}),
|
|
55
|
+
validateTransition: ({ mode, before, after, current, candidate }) => {
|
|
56
|
+
if (before.status === "needs-human") {
|
|
57
|
+
if (mode === "amend-paths") {
|
|
58
|
+
if (after.status !== "needs-human") throw new Error("amend-paths must preserve parked status");
|
|
59
|
+
if (!isDeepStrictEqual(after.terminal_result, before.terminal_result)) {
|
|
60
|
+
throw new Error("amend-paths must preserve terminal_result");
|
|
61
|
+
}
|
|
62
|
+
if (Date.parse(after.updated_at) <= Date.parse(before.updated_at)) {
|
|
63
|
+
throw new Error("amend-paths must move updated_at forwards");
|
|
64
|
+
}
|
|
65
|
+
for (const key of Object.keys(before).filter((key) => key !== "updated_at")) {
|
|
66
|
+
if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`amend-paths cannot change envelope.${key}`);
|
|
67
|
+
}
|
|
68
|
+
for (const key of Object.keys(current).filter((key) => !Object.hasOwn(before, key) && key !== "slices")) {
|
|
69
|
+
if (!isDeepStrictEqual(current[key], candidate[key])) throw new Error(`amend-paths cannot change run.${key}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!["resume-needs-human", "record-bootstrap"].includes(mode)) throw new Error("a needs-human run must be resumed before any transition");
|
|
74
|
+
const targetStatus = mode === "resume-needs-human" ? "running" : "needs-human";
|
|
75
|
+
if (after.status !== targetStatus) throw new Error(`${mode} must change status to ${targetStatus}`);
|
|
76
|
+
if (!isDeepStrictEqual(after.terminal_result, before.terminal_result)) {
|
|
77
|
+
throw new Error("resume-needs-human must preserve terminal_result");
|
|
78
|
+
}
|
|
79
|
+
if (Date.parse(after.updated_at) <= Date.parse(before.updated_at)) {
|
|
80
|
+
throw new Error("resume-needs-human must move updated_at forwards");
|
|
81
|
+
}
|
|
82
|
+
for (const key of Object.keys(before).filter((key) => !["status", "updated_at", "bootstrap_command", "bootstrap_exit"].includes(key))) {
|
|
83
|
+
if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`resume-needs-human cannot change envelope.${key}`);
|
|
84
|
+
}
|
|
85
|
+
for (const key of Object.keys(current).filter((key) => !Object.hasOwn(before, key))) {
|
|
86
|
+
if (!isDeepStrictEqual(current[key], candidate[key])) throw new Error(`resume-needs-human cannot change run.${key}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (mode === "amend-paths") throw new Error(`amend-paths requires current status needs-human; found '${before.status}'`);
|
|
91
|
+
if (["resume-needs-human", "record-bootstrap"].includes(mode)) throw new Error(`${mode} requires current status needs-human; found '${before.status}'`);
|
|
92
|
+
// Identity is immutable for the life of a run. Nothing legitimate renames a
|
|
93
|
+
// run, and allowing it would let a transition retarget another run's record.
|
|
94
|
+
for (const key of ["run_id", "created_at", "pr_base", "pr_draft", "mode"]) {
|
|
95
|
+
if (before[key] !== after[key]) throw new Error(`envelope.${key} is immutable`);
|
|
96
|
+
}
|
|
97
|
+
for (const key of ["bootstrap_command", "bootstrap_exit"]) {
|
|
98
|
+
if (!isDeepStrictEqual(before[key], after[key])) throw new Error(`envelope.${key} may change only during bootstrap resume`);
|
|
99
|
+
}
|
|
100
|
+
if (Date.parse(after.updated_at) < Date.parse(before.updated_at)) {
|
|
101
|
+
throw new Error("envelope.updated_at cannot move backwards");
|
|
102
|
+
}
|
|
103
|
+
const wasTerminal = TERMINAL_STATUSES.includes(before.status);
|
|
104
|
+
const isTerminal = TERMINAL_STATUSES.includes(after.status);
|
|
105
|
+
if (wasTerminal && before.status !== after.status) {
|
|
106
|
+
throw new Error(`envelope cannot leave terminal status ${before.status}`);
|
|
107
|
+
}
|
|
108
|
+
if (isTerminal && !wasTerminal && !TERMINAL_MODES.has(mode)) {
|
|
109
|
+
throw new Error(`only a terminalize transition may enter ${after.status}`);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// gates — the three human approval gates
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Approving Gate 3 is the moment publication becomes authorized, and in the skill's flow
|
|
118
|
+
// it is also the last moment before the branch is pushed and the PR is created. Checks
|
|
119
|
+
// that live only in `factory pr` run after both of those effects, so they can report a
|
|
120
|
+
// bad publication but not prevent one. The readiness check is therefore invoked here as
|
|
121
|
+
// well, and an approval that arrives without an observer is refused rather than trusted:
|
|
122
|
+
// the two dead reobservers this codebase already found were both "registered but never
|
|
123
|
+
// called", which fails open exactly here.
|
|
124
|
+
async function checkPrePrApproval({ observe, current, candidate, state, nextState }) {
|
|
125
|
+
const wasApproved = current?.pre_pr?.status === "approved";
|
|
126
|
+
const isApproved = candidate?.pre_pr?.status === "approved";
|
|
127
|
+
if (!isApproved || wasApproved) return callRegisteredObserver({ observe, current, candidate, state, nextState });
|
|
128
|
+
if (typeof observe !== "function") {
|
|
129
|
+
throw new Error("approving the pre_pr gate requires a publication-readiness observer");
|
|
130
|
+
}
|
|
131
|
+
await observe({ current, candidate, state, nextState });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const gates = contract({
|
|
135
|
+
id: "gates",
|
|
136
|
+
reobserve: checkPrePrApproval,
|
|
137
|
+
project: (state) => ({ ...(state.gates ?? {}) }),
|
|
138
|
+
validateTransition: ({ before, after, candidate }) => {
|
|
139
|
+
for (const name of GATE_NAMES) {
|
|
140
|
+
const from = before[name];
|
|
141
|
+
const to = after[name];
|
|
142
|
+
if (to === undefined) continue;
|
|
143
|
+
if (from === undefined) {
|
|
144
|
+
// A gate may appear only as pending; a gate that springs into existence
|
|
145
|
+
// already approved is a decision nobody made.
|
|
146
|
+
if (to.status !== "pending") throw new Error(`gate '${name}' must open as pending`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const decided = from.status !== "pending";
|
|
150
|
+
const reopening = decided && to.status === "pending";
|
|
151
|
+
// The artifact is *what was decided against*, so a decided gate's artifact is frozen.
|
|
152
|
+
// Checked before the unchanged-status early-out, because that is where it was
|
|
153
|
+
// reachable: re-deciding a gate to the status it already held skipped every check
|
|
154
|
+
// below, and the handler writes whatever --artifact it is given, so an approved Story
|
|
155
|
+
// could be pointed at a new document in place, without re-opening anything.
|
|
156
|
+
if (decided && !reopening && from.artifact !== to.artifact) {
|
|
157
|
+
throw new Error(`gate '${name}' artifact is what was decided against and cannot change`);
|
|
158
|
+
}
|
|
159
|
+
// Every gate is compared on every gates transition, so a gate nobody touched must fall
|
|
160
|
+
// through here rather than be judged again.
|
|
161
|
+
if (from.status === to.status) continue;
|
|
162
|
+
// Re-opening turns on what was decided and on whether anything downstream would be stranded.
|
|
163
|
+
// `changes` asks for another round and every later stage requires this gate approved, so
|
|
164
|
+
// nothing rests on it. An approved gate is the hazard — one re-opened once published Story v1's
|
|
165
|
+
// implementation under Story v2 — but that needs built work, and before seeding there is none.
|
|
166
|
+
const seeded = (candidate.slices ?? []).length > 0;
|
|
167
|
+
const mayReopen = from.status === "changes" || name === "pre_pr"
|
|
168
|
+
|| (from.status === "approved" && !seeded);
|
|
169
|
+
if (reopening && !mayReopen) {
|
|
170
|
+
throw new Error(`gate '${name}' cannot be re-opened once ${from.status}${seeded ? " and its plan is seeded" : ""}`);
|
|
171
|
+
}
|
|
172
|
+
if (decided && !reopening) {
|
|
173
|
+
throw new Error(`gate '${name}' is already decided as ${from.status}`);
|
|
174
|
+
}
|
|
175
|
+
if (!GATE_STATUSES.includes(to.status)) throw new Error(`gate '${name}' status is invalid`);
|
|
176
|
+
if (to.status !== "pending" && !to.at) throw new Error(`gate '${name}' decision requires 'at'`);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// steps — agent step rows
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
const steps = contract({
|
|
185
|
+
id: "steps",
|
|
186
|
+
project: (state) => (state.steps ?? []).map((step) => ({ ...step })),
|
|
187
|
+
validateTransition: ({ before, after, candidate }) => {
|
|
188
|
+
const priorByAgent = new Map(before.map((step) => [step.agent, step]));
|
|
189
|
+
for (const step of after) {
|
|
190
|
+
const prior = priorByAgent.get(step.agent);
|
|
191
|
+
if (!prior) {
|
|
192
|
+
if (step.attempts !== 1) throw new Error(`step '${step.agent}' must start at attempt 1`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (step.attempts < prior.attempts) throw new Error(`step '${step.agent}' attempts cannot decrease`);
|
|
196
|
+
if (step.attempts > prior.attempts + 1) throw new Error(`step '${step.agent}' attempts cannot skip`);
|
|
197
|
+
if (prior.status === "accepted" && step.status !== "accepted") {
|
|
198
|
+
throw new Error(`step '${step.agent}' is already accepted`);
|
|
199
|
+
}
|
|
200
|
+
if (!STEP_STATUSES.includes(step.status)) throw new Error(`step '${step.agent}' status is invalid`);
|
|
201
|
+
}
|
|
202
|
+
// Bounded loops: the inherited max_retries limit is enforced rather than instructed.
|
|
203
|
+
for (const step of after) {
|
|
204
|
+
if (step.attempts > candidate.max_retries && step.status !== "blocked") {
|
|
205
|
+
throw new Error(`step '${step.agent}' exhausted max_retries and must be blocked`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// A step row may never disappear; that would erase an attempt record.
|
|
209
|
+
for (const agent of priorByAgent.keys()) {
|
|
210
|
+
if (!after.some((step) => step.agent === agent)) throw new Error(`step '${agent}' cannot be removed`);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// slices — slice rows, attempts, merge_commit
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Attack 5 lives here rather than in the CLI. `reobserve` runs inside the
|
|
219
|
+
// transition, immediately before the atomic rename, so a merge cannot be recorded
|
|
220
|
+
// without the ownership check having run against freshly observed paths.
|
|
221
|
+
// The write core calls reobserve with { mode, current, candidate, observe, state,
|
|
222
|
+
// nextState } — `current` is the family's freshly re-read projection and `candidate`
|
|
223
|
+
// is the projection about to be written. An earlier version destructured
|
|
224
|
+
// before/after here, which are the validateTransition names, so `candidate` was
|
|
225
|
+
// undefined and this guard threw before checking anything. It read as enforcement
|
|
226
|
+
// and was dead. The end-to-end test caught it; the unit tests could not, because
|
|
227
|
+
// they called the path helpers directly and never went through the hook.
|
|
228
|
+
async function reobserveSlices({ mode, current, candidate, observe }) {
|
|
229
|
+
if (mode === "amend-paths") {
|
|
230
|
+
if (typeof observe !== "function") throw new Error("amend-paths requires a session-owner observer");
|
|
231
|
+
const changed = candidate.find((slice, index) => !isDeepStrictEqual(slice, current[index]));
|
|
232
|
+
const observed = await observe(changed);
|
|
233
|
+
if (observed?.authorized_session !== changed?.path_amendments?.at(-1)?.session) {
|
|
234
|
+
throw new Error("amend-paths requires exact observed session ownership");
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (mode !== "merge") return;
|
|
239
|
+
const priorSlices = Array.isArray(current) ? current : [];
|
|
240
|
+
const nextSlices = Array.isArray(candidate) ? candidate : [];
|
|
241
|
+
const newlyMerged = nextSlices.filter((slice) => slice.status === "merged"
|
|
242
|
+
&& priorSlices.find((prior) => prior.id === slice.id)?.status !== "merged");
|
|
243
|
+
if (newlyMerged.length === 0) return;
|
|
244
|
+
if (typeof observe !== "function") {
|
|
245
|
+
// Fail closed: a merge whose paths cannot be observed is not a merge we can
|
|
246
|
+
// authorize. Recording it anyway is precisely the false green being prevented.
|
|
247
|
+
throw new Error("a merge transition requires a path observer");
|
|
248
|
+
}
|
|
249
|
+
for (const slice of newlyMerged) {
|
|
250
|
+
const observed = await observe(slice);
|
|
251
|
+
if (!observed || observed.diff_observed !== true) {
|
|
252
|
+
throw new Error(`slice '${slice.id}' merge requires observed changed paths`);
|
|
253
|
+
}
|
|
254
|
+
// Privilege is reported before ownership. A privileged path is almost always
|
|
255
|
+
// also unowned, so checking ownership first made the privileged message
|
|
256
|
+
// unreachable - and the two are different findings: exceeding your lane is a
|
|
257
|
+
// planning problem, touching the control plane is not.
|
|
258
|
+
if (observed.privileged.length > 0) {
|
|
259
|
+
throw new Error(`slice '${slice.id}' changed privileged control-plane paths: ${observed.privileged.join(", ")}`);
|
|
260
|
+
}
|
|
261
|
+
if (observed.unowned.length > 0) {
|
|
262
|
+
throw new Error(`slice '${slice.id}' changed paths it does not own: ${observed.unowned.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const slices = contract({
|
|
268
|
+
id: "slices",
|
|
269
|
+
reobserve: reobserveSlices,
|
|
270
|
+
project: (state) => (state.slices ?? []).map((slice) => ({ ...slice })),
|
|
271
|
+
validateTransition: ({ mode, before, after, candidate }) => {
|
|
272
|
+
if (mode === "amend-paths") {
|
|
273
|
+
if (before.length !== after.length) throw new Error("amend-paths cannot add or remove slices");
|
|
274
|
+
const changed = after.map((slice, index) => ({ slice, index }))
|
|
275
|
+
.filter(({ slice, index }) => !isDeepStrictEqual(slice, before[index]));
|
|
276
|
+
if (changed.length !== 1) throw new Error("amend-paths must change exactly one slice");
|
|
277
|
+
const { slice, index } = changed[0];
|
|
278
|
+
const prior = before[index];
|
|
279
|
+
if (prior.id !== slice.id) throw new Error("amend-paths cannot reorder or replace slices");
|
|
280
|
+
if (prior.status === "merged") throw new Error(`slice '${prior.id}' is already merged`);
|
|
281
|
+
for (const key of new Set([...Object.keys(prior), ...Object.keys(slice)])) {
|
|
282
|
+
if (!["paths", "path_amendments"].includes(key) && !isDeepStrictEqual(prior[key], slice[key])) {
|
|
283
|
+
throw new Error(`amend-paths cannot change slice '${prior.id}' ${key}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const priorPaths = prior.paths ?? [];
|
|
287
|
+
const addedPaths = slice.paths?.slice(priorPaths.length) ?? [];
|
|
288
|
+
if (addedPaths.length === 0 || !isDeepStrictEqual(slice.paths.slice(0, priorPaths.length), priorPaths)) {
|
|
289
|
+
throw new Error(`amend-paths must append paths to slice '${prior.id}'`);
|
|
290
|
+
}
|
|
291
|
+
const priorHistory = prior.path_amendments ?? [];
|
|
292
|
+
const nextHistory = slice.path_amendments;
|
|
293
|
+
if (!Array.isArray(nextHistory) || nextHistory.length !== priorHistory.length + 1
|
|
294
|
+
|| !isDeepStrictEqual(nextHistory.slice(0, priorHistory.length), priorHistory)) {
|
|
295
|
+
throw new Error(`amend-paths must append one history record to slice '${prior.id}'`);
|
|
296
|
+
}
|
|
297
|
+
const amendment = nextHistory.at(-1);
|
|
298
|
+
if (!isDeepStrictEqual(amendment.added_paths, addedPaths) || amendment.at !== candidate.updated_at) {
|
|
299
|
+
throw new Error(`amend-paths history must match slice '${prior.id}' path additions and updated_at`);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const priorById = new Map(before.map((slice) => [slice.id, slice]));
|
|
304
|
+
for (const slice of after) {
|
|
305
|
+
const prior = priorById.get(slice.id);
|
|
306
|
+
if (!prior) {
|
|
307
|
+
if (mode === "seed" && !isDeepStrictEqual(slice.path_amendments, [])) {
|
|
308
|
+
throw new Error(`seeded slice '${slice.id}' path_amendments must start empty`);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// Finding 3: base_ref was replaceable on every update, so supplying the slice
|
|
313
|
+
// head as its own base made the diff empty and every ownership check vacuous.
|
|
314
|
+
// It is the branch point, which is a fact about the past: writable once, then
|
|
315
|
+
// fixed.
|
|
316
|
+
if (prior.base_ref && slice.base_ref !== prior.base_ref) {
|
|
317
|
+
throw new Error(`slice '${slice.id}' base_ref is immutable once recorded`);
|
|
318
|
+
}
|
|
319
|
+
// Enforcement: only amend-paths may append authorized ownership and its audit record;
|
|
320
|
+
// every other transition keeps ownership, history, and the ratified test plan immutable.
|
|
321
|
+
for (const field of ["paths", "path_amendments", "test_plan"]) {
|
|
322
|
+
if (JSON.stringify(prior[field]) !== JSON.stringify(slice[field])) {
|
|
323
|
+
throw new Error(`slice '${slice.id}' ${field} cannot change in ${mode ?? "an undeclared mode"}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (slice.attempts < prior.attempts) throw new Error(`slice '${slice.id}' attempts cannot decrease`);
|
|
327
|
+
if (slice.attempts > prior.attempts + 1) throw new Error(`slice '${slice.id}' attempts cannot skip`);
|
|
328
|
+
if (prior.status === "merged" && slice.status !== "merged") {
|
|
329
|
+
throw new Error(`slice '${slice.id}' is already merged`);
|
|
330
|
+
}
|
|
331
|
+
if (prior.status === "merged" && prior.merge_commit !== slice.merge_commit) {
|
|
332
|
+
throw new Error(`slice '${slice.id}' merge_commit is immutable once merged`);
|
|
333
|
+
}
|
|
334
|
+
if (!SLICE_STATUSES.includes(slice.status)) throw new Error(`slice '${slice.id}' status is invalid`);
|
|
335
|
+
if (slice.attempts > candidate.max_retries && !["merged", "blocked"].includes(slice.status)) {
|
|
336
|
+
throw new Error(`slice '${slice.id}' exhausted max_retries and must be blocked`);
|
|
337
|
+
}
|
|
338
|
+
// Dependency order: a slice cannot merge before everything it depends on.
|
|
339
|
+
if (slice.status === "merged") {
|
|
340
|
+
for (const dep of slice.depends_on ?? []) {
|
|
341
|
+
const dependency = after.find((entry) => entry.id === dep);
|
|
342
|
+
if (dependency?.status !== "merged") {
|
|
343
|
+
throw new Error(`slice '${slice.id}' cannot merge before dependency '${dep}'`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const id of priorById.keys()) {
|
|
349
|
+
if (!after.some((slice) => slice.id === id)) throw new Error(`slice '${id}' cannot be removed`);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// verdict — validator verdict and pr_url
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
async function checkPublication({ mode, observe, current, candidate, state, nextState }) {
|
|
358
|
+
if (mode !== "publish") return;
|
|
359
|
+
if (typeof observe !== "function") throw new Error("publishing a PR requires an observer");
|
|
360
|
+
await observe({ current, candidate, state, nextState });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const verdict = contract({
|
|
364
|
+
id: "verdict",
|
|
365
|
+
reobserve: checkPublication,
|
|
366
|
+
project: (state) => ({ validator: state.validator ?? null, pr_url: state.pr_url ?? null }),
|
|
367
|
+
validateTransition: ({ before, after, candidate }) => {
|
|
368
|
+
if (before.pr_url && before.pr_url !== after.pr_url) {
|
|
369
|
+
// Exactly-once: a run has one PR. Overwriting the URL would hide a second.
|
|
370
|
+
throw new Error("pr_url is immutable once recorded");
|
|
371
|
+
}
|
|
372
|
+
const priorLoops = before.validator?.loops ?? 0;
|
|
373
|
+
const nextLoops = after.validator?.loops ?? 0;
|
|
374
|
+
if (nextLoops < priorLoops) throw new Error("validator.loops cannot decrease");
|
|
375
|
+
// What the human approved at Gate 3 was this verdict against this head. The gate record
|
|
376
|
+
// stores only a status and a time, so re-recording the verdict afterwards silently
|
|
377
|
+
// re-points that approval at whatever the branch has become: opencode approved at one
|
|
378
|
+
// head, committed directly to reach a second, re-observed the tests and re-recorded the
|
|
379
|
+
// verdict there, then published without re-presenting the gate. Every machine check was
|
|
380
|
+
// current and the human decision was stale.
|
|
381
|
+
//
|
|
382
|
+
// Freezing the verdict while the gate stands is what makes the approval mean a commit,
|
|
383
|
+
// without storing a second copy of the head for the two records to disagree about. It
|
|
384
|
+
// is not a dead end: re-open Gate 3 as pending, re-validate, and present it again. So
|
|
385
|
+
// the cost of a late change is one more approval, not a lost run.
|
|
386
|
+
if (candidate.gates?.pre_pr?.status === "approved"
|
|
387
|
+
&& JSON.stringify(before.validator) !== JSON.stringify(after.validator)) {
|
|
388
|
+
throw new Error("the pre_pr gate is approved; re-open it as pending before re-recording the validator");
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
export const FAMILY_CONTRACTS = Object.freeze([envelope, gates, steps, slices, verdict]);
|
|
394
|
+
export const FAMILY_IDS = Object.freeze(FAMILY_CONTRACTS.map((entry) => entry.id));
|