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,169 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { writeProtectedJsonAtomic } from "../core/atomic-write.js";
|
|
7
|
+
import { withRunJsonLock } from "../core/run-lock.js";
|
|
8
|
+
import { git, observeCleanliness } from "./index.js";
|
|
9
|
+
import { REPAIR_EVIDENCE_PREFIX, readRepairState } from "./repair-record.js";
|
|
10
|
+
|
|
11
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
12
|
+
const digest = (value) => `sha256:${createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex")}`;
|
|
13
|
+
const sameNames = (left, right) => JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
14
|
+
|
|
15
|
+
function assertEnvelope(run) {
|
|
16
|
+
if (!["running", "needs-human"].includes(run.status)) {
|
|
17
|
+
throw new Error(`factory reverify-repair requires run status running or needs-human; found '${run.status}'`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertDetached(worktree, commit) {
|
|
22
|
+
const head = git(worktree, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
23
|
+
if (!head.ok || head.stdout.trim() !== commit) throw new Error(`detached repair worktree is not at immutable repair commit ${commit}`);
|
|
24
|
+
const symbolic = git(worktree, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
25
|
+
if (symbolic.status !== 1) throw new Error("repair worktree is not detached");
|
|
26
|
+
const cleanliness = observeCleanliness(worktree);
|
|
27
|
+
if (!cleanliness.clean) throw new Error(`detached repair worktree is not initially clean: ${cleanliness.reason}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureEvidenceDirectory(runDir) {
|
|
31
|
+
const path = join(runDir, "evidence");
|
|
32
|
+
try {
|
|
33
|
+
const stats = lstatSync(path);
|
|
34
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("repair evidence path is not a regular directory");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code !== "ENOENT") throw error;
|
|
37
|
+
mkdirSync(path, { mode: 0o700 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function evidenceNames(recordId, attempt) {
|
|
42
|
+
const stem = `${REPAIR_EVIDENCE_PREFIX}${recordId}.${attempt}`;
|
|
43
|
+
return { marker: `evidence/${stem}.started.json`, result: `evidence/${stem}.json` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createDetached(repo, commit) {
|
|
47
|
+
const parent = mkdtempSync(join(tmpdir(), "factory-reverify-repair-"));
|
|
48
|
+
const worktree = join(parent, "worktree");
|
|
49
|
+
const added = git(repo, ["worktree", "add", "--detach", worktree, commit]);
|
|
50
|
+
if (!added.ok) {
|
|
51
|
+
rmSync(parent, { recursive: true, force: true });
|
|
52
|
+
throw new Error(`could not create detached repair worktree at ${commit}`);
|
|
53
|
+
}
|
|
54
|
+
return { parent, worktree };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function removeDetached(repo, temporary) {
|
|
58
|
+
const removed = git(repo, ["worktree", "remove", "--force", temporary.worktree]);
|
|
59
|
+
if (!removed.ok || existsSync(temporary.worktree)) {
|
|
60
|
+
throw new Error(`repair worktree cleanup failed; retained at ${temporary.worktree}`);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
rmSync(temporary.parent, { recursive: true, force: false });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`repair worktree cleanup failed for ${temporary.parent}: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function reverifyRepair({ repo, runDir, runId, recordId, at }) {
|
|
70
|
+
const parsedAt = typeof at === "string" ? Date.parse(at) : NaN;
|
|
71
|
+
if (!Number.isFinite(parsedAt) || new Date(parsedAt).toISOString() !== at) throw new Error("repair re-verification timestamp is not canonical");
|
|
72
|
+
const preread = readRepairState({ repo, runDir, runId, recordId });
|
|
73
|
+
assertEnvelope(preread.run);
|
|
74
|
+
if (preread.selectedHistory.pass !== null) throw new Error(`repair record '${recordId}' is already effectively verified`);
|
|
75
|
+
if (preread.selectedHistory.tail) throw new Error(`repair record '${recordId}' has a marker-only attempt requiring manual resolution`);
|
|
76
|
+
const temporary = createDetached(repo, preread.selected.repair_commit);
|
|
77
|
+
try {
|
|
78
|
+
assertDetached(temporary.worktree, preread.selected.repair_commit);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
try { removeDetached(repo, temporary); } catch (cleanup) { throw new Error(`${error.message}; ${cleanup.message}`); }
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let reservation;
|
|
85
|
+
try {
|
|
86
|
+
reservation = await withRunJsonLock(runDir, async () => {
|
|
87
|
+
const current = readRepairState({ repo, runDir, runId, recordId });
|
|
88
|
+
assertEnvelope(current.run);
|
|
89
|
+
assertDetached(temporary.worktree, current.selected.repair_commit);
|
|
90
|
+
const history = current.selectedHistory;
|
|
91
|
+
if (history.pass !== null) throw new Error(`repair record '${recordId}' is already effectively verified`);
|
|
92
|
+
if (history.tail) throw new Error(`repair record '${recordId}' has a marker-only attempt requiring manual resolution`);
|
|
93
|
+
const attempt = history.attempts + 1;
|
|
94
|
+
const refs = evidenceNames(recordId, attempt);
|
|
95
|
+
const marker = {
|
|
96
|
+
version: 1, run_id: runId, record_id: recordId, attempt,
|
|
97
|
+
run_sha256: digest(current.run), journal_sha256: digest(current.journal), record_sha256: digest(current.selected),
|
|
98
|
+
introducing_merge: current.selected.introducing_merge, repair_commit: current.selected.repair_commit,
|
|
99
|
+
trigger: current.selected.trigger, started_at: at,
|
|
100
|
+
};
|
|
101
|
+
ensureEvidenceDirectory(runDir);
|
|
102
|
+
await writeProtectedJsonAtomic(runDir, refs.marker, marker, { createOnly: true });
|
|
103
|
+
const reread = readRepairState({ repo, runDir, runId, recordId });
|
|
104
|
+
if (!reread.selectedHistory.tail || reread.selectedHistory.attempts !== attempt
|
|
105
|
+
|| !sameNames(reread.selectedHistory.names, [...history.names, refs.marker.slice("evidence/".length)])
|
|
106
|
+
|| JSON.stringify(reread.selectedHistory.markers.get(attempt)) !== JSON.stringify(marker)) {
|
|
107
|
+
throw new Error("repair marker did not read back as the unique next attempt");
|
|
108
|
+
}
|
|
109
|
+
return { attempt, refs, marker, runBytes: Buffer.from(current.runBytes), journalBytes: Buffer.from(current.journalBytes),
|
|
110
|
+
priorNames: [...history.names], repairCommit: current.selected.repair_commit, trigger: current.selected.trigger };
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
try { removeDetached(repo, temporary); } catch (cleanup) { throw new Error(`${error.message}; ${cleanup.message}`); }
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let execution;
|
|
118
|
+
try {
|
|
119
|
+
const outcome = spawnSync(reservation.trigger.command, [], {
|
|
120
|
+
cwd: temporary.worktree, shell: true, env: process.env, stdio: "inherit", timeout: reservation.trigger.timeout_ms,
|
|
121
|
+
});
|
|
122
|
+
const exit = Number.isSafeInteger(outcome?.status) && outcome.status >= 0 ? outcome.status : null;
|
|
123
|
+
const head = git(temporary.worktree, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
124
|
+
const commit = head.ok && SHA.test(head.stdout.trim()) ? head.stdout.trim() : null;
|
|
125
|
+
const cleanliness = observeCleanliness(temporary.worktree);
|
|
126
|
+
execution = { observed: exit !== null, exit, commit, worktree_clean: cleanliness.clean };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
try { removeDetached(repo, temporary); } catch (cleanup) { throw new Error(`${error.message}; ${cleanup.message}`); }
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
removeDetached(repo, temporary);
|
|
132
|
+
|
|
133
|
+
const completed = await withRunJsonLock(runDir, async () => {
|
|
134
|
+
const current = readRepairState({ repo, runDir, runId, recordId });
|
|
135
|
+
assertEnvelope(current.run);
|
|
136
|
+
if (!current.runBytes.equals(reservation.runBytes) || !current.journalBytes.equals(reservation.journalBytes)) {
|
|
137
|
+
throw new Error("run or repair journal bytes changed during re-verification");
|
|
138
|
+
}
|
|
139
|
+
const history = current.selectedHistory;
|
|
140
|
+
const markerName = reservation.refs.marker.slice("evidence/".length);
|
|
141
|
+
if (!history.tail || history.attempts !== reservation.attempt
|
|
142
|
+
|| !sameNames(history.names, [...reservation.priorNames, markerName])
|
|
143
|
+
|| JSON.stringify(history.markers.get(reservation.attempt)) !== JSON.stringify(reservation.marker)
|
|
144
|
+
|| current.selected.repair_commit !== reservation.repairCommit
|
|
145
|
+
|| JSON.stringify(current.selected.trigger) !== JSON.stringify(reservation.trigger)) {
|
|
146
|
+
throw new Error("repair reservation changed before result publication");
|
|
147
|
+
}
|
|
148
|
+
const result = {
|
|
149
|
+
version: 1, run_id: runId, record_id: recordId, attempt: reservation.attempt,
|
|
150
|
+
marker_sha256: digest(reservation.marker), run_sha256: reservation.marker.run_sha256,
|
|
151
|
+
journal_sha256: reservation.marker.journal_sha256, record_sha256: reservation.marker.record_sha256,
|
|
152
|
+
introducing_merge: reservation.marker.introducing_merge, repair_commit: reservation.repairCommit,
|
|
153
|
+
trigger: reservation.trigger, result: execution, observed_at: at, observed_by: "factory",
|
|
154
|
+
};
|
|
155
|
+
await writeProtectedJsonAtomic(runDir, reservation.refs.result, result, { createOnly: true });
|
|
156
|
+
const reread = readRepairState({ repo, runDir, runId, recordId });
|
|
157
|
+
if (reread.selectedHistory.tail || reread.selectedHistory.attempts !== reservation.attempt
|
|
158
|
+
|| JSON.stringify(reread.selectedHistory.results.get(reservation.attempt)) !== JSON.stringify(result)) {
|
|
159
|
+
throw new Error("repair result did not read back as the unique final attempt");
|
|
160
|
+
}
|
|
161
|
+
return { result, effective: reread.selectedHistory.pass === reservation.attempt };
|
|
162
|
+
});
|
|
163
|
+
if (!completed.effective) throw new Error(`repair re-verification attempt ${reservation.attempt} did not pass`);
|
|
164
|
+
return {
|
|
165
|
+
run_id: runId, record_id: recordId, attempt: reservation.attempt,
|
|
166
|
+
physical_status: "needs-human", effective_status: "verified", repair_commit: reservation.repairCommit,
|
|
167
|
+
evidence_ref: reservation.refs.result,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEFAULT_BOOTSTRAP_TIMEOUT_MS, DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export class RepositoryConfigError extends Error {}
|
|
6
|
+
|
|
7
|
+
export function parseRepositoryConfig(bytes) {
|
|
8
|
+
let config;
|
|
9
|
+
try {
|
|
10
|
+
config = JSON.parse(Buffer.isBuffer(bytes) ? bytes.toString("utf8") : bytes);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new RepositoryConfigError("invalid .factory.json");
|
|
13
|
+
}
|
|
14
|
+
const requiredKeys = ["publish", "publishing_identity", "resolve", "verify"];
|
|
15
|
+
const allowedKeys = [...requiredKeys, "pr_draft", "verify_timeout_ms", "bootstrap", "bootstrap_timeout_ms"];
|
|
16
|
+
if (!config || typeof config !== "object" || Array.isArray(config)
|
|
17
|
+
|| Object.keys(config).some((keyName) => !allowedKeys.includes(keyName))) {
|
|
18
|
+
throw new RepositoryConfigError("invalid .factory.json");
|
|
19
|
+
}
|
|
20
|
+
if (Object.hasOwn(config, "pr_draft") && typeof config.pr_draft !== "boolean") {
|
|
21
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'pr_draft' must be a boolean");
|
|
22
|
+
}
|
|
23
|
+
const hasBootstrap = Object.hasOwn(config, "bootstrap");
|
|
24
|
+
const hasBootstrapTimeout = Object.hasOwn(config, "bootstrap_timeout_ms");
|
|
25
|
+
if (hasBootstrap && (typeof config.bootstrap !== "string" || !config.bootstrap.trim())) {
|
|
26
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'bootstrap' must be a non-empty string");
|
|
27
|
+
}
|
|
28
|
+
if (!hasBootstrap && hasBootstrapTimeout) {
|
|
29
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'bootstrap_timeout_ms' requires a declared bootstrap command");
|
|
30
|
+
}
|
|
31
|
+
if (hasBootstrapTimeout && (!Number.isSafeInteger(config.bootstrap_timeout_ms) || config.bootstrap_timeout_ms <= 0)) {
|
|
32
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'bootstrap_timeout_ms' must be a positive integer");
|
|
33
|
+
}
|
|
34
|
+
if (Object.hasOwn(config, "verify_timeout_ms")
|
|
35
|
+
&& (!Number.isSafeInteger(config.verify_timeout_ms) || config.verify_timeout_ms <= 0)) {
|
|
36
|
+
throw new RepositoryConfigError("invalid .factory.json: entry 'verify_timeout_ms' must be a positive integer");
|
|
37
|
+
}
|
|
38
|
+
if (requiredKeys.some((keyName) => typeof config[keyName] !== "string" || !config[keyName].trim())) {
|
|
39
|
+
throw new RepositoryConfigError("invalid .factory.json");
|
|
40
|
+
}
|
|
41
|
+
const parsed = { command: config.verify, timeoutMs: config.verify_timeout_ms ?? DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS,
|
|
42
|
+
prDraft: config.pr_draft ?? true };
|
|
43
|
+
return hasBootstrap ? { ...parsed, bootstrapCommand: config.bootstrap,
|
|
44
|
+
bootstrapTimeoutMs: config.bootstrap_timeout_ms ?? DEFAULT_BOOTSTRAP_TIMEOUT_MS } : parsed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readRepositoryConfig(worktree, { optional = false } = {}) {
|
|
48
|
+
let bytes;
|
|
49
|
+
try {
|
|
50
|
+
bytes = readFileSync(join(worktree, ".factory.json"), "utf8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (optional && error?.code === "ENOENT") return null;
|
|
53
|
+
throw new RepositoryConfigError("invalid .factory.json");
|
|
54
|
+
}
|
|
55
|
+
return parseRepositoryConfig(bytes);
|
|
56
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Review records need bindings to mean anything. The inherited record did not say
|
|
2
|
+
// what code was reviewed; a human could see the diff, but an autonomous run cannot.
|
|
3
|
+
// Every verdict therefore names the commit it judged, preventing approval against a
|
|
4
|
+
// different commit, a divergent merged tree, or a stale validator head. Each is the
|
|
5
|
+
// same defect: a judgement detached from its subject.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { isAbsolute, sep } from "node:path";
|
|
9
|
+
import { deriveReviewReady, EVIDENCE_DIR, EVIDENCE_KEYS, evidenceRef, git, observeAncestry } from "./index.js";
|
|
10
|
+
import { assertRepairPublicationReady } from "./repair-record.js";
|
|
11
|
+
import { GATE_NAMES, TERMINAL_STATUSES, VALIDATOR_VERDICTS } from "../state/schema.js";
|
|
12
|
+
|
|
13
|
+
export const REVIEW_KEYS = Object.freeze([
|
|
14
|
+
"subject", "reviewer", "verdict", "attempt", "reviewed_commit",
|
|
15
|
+
"findings", "required_fixes", "checked_against",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const APPROVING_VERDICTS = Object.freeze(["APPROVE", "GO", "GO-WITH-NITS", "PASS"]);
|
|
19
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
20
|
+
|
|
21
|
+
export const REVIEWS_DIR = "reviews";
|
|
22
|
+
export const reviewRef = (subject) => join(REVIEWS_DIR, `${subject}.json`);
|
|
23
|
+
|
|
24
|
+
export function readReview(runDir, ref) {
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(readFileSync(join(runDir, ref), "utf8"));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`review '${ref}' could not be read: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
// Accumulate, then throw once. Fail-fast costs one dispatch per problem: run 1437's validator record had
|
|
32
|
+
// two unknown keys, no reviewed_commit and no attempt, so correcting the keys would have earned a second
|
|
33
|
+
// refusal and then a third, each after another validator pass over a 27-file change.
|
|
34
|
+
const unknown = Object.keys(value).filter((key) => !REVIEW_KEYS.includes(key));
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (unknown.length > 0) problems.push(`has unknown keys: ${unknown.sort().join(", ")}`);
|
|
37
|
+
if (typeof value.subject !== "string" || !value.subject.trim()) problems.push("has no subject");
|
|
38
|
+
if (typeof value.verdict !== "string" || !value.verdict.trim()) problems.push("has no verdict");
|
|
39
|
+
if (!Number.isSafeInteger(value.attempt) || value.attempt < 1) problems.push("has no attempt");
|
|
40
|
+
// The binding this whole module exists for. A review without it cannot be
|
|
41
|
+
// consumed, rather than being consumed against an assumed commit.
|
|
42
|
+
if (!SHA.test(String(value.reviewed_commit))) problems.push("must record reviewed_commit as a full 40-character sha");
|
|
43
|
+
// Enforcement: the four keys below were rejected when unknown but never required, so a record naming only a
|
|
44
|
+
// subject, verdict, attempt and binding passed every check and was consumed as a complete approval. That is
|
|
45
|
+
// the evidence this step exists to produce -- `checked_against` is what the reviewer read and `findings` is
|
|
46
|
+
// what it found -- so a partial record is weaker evidence being trusted as whole. The agent prose has
|
|
47
|
+
// required all eight since #300; this closes the half the CLI was not checking.
|
|
48
|
+
//
|
|
49
|
+
// Presence and type only, never key order: order carries no meaning in JSON, and refusing a semantically
|
|
50
|
+
// complete record over its formatting is the over-reach that cost run 291 its work.
|
|
51
|
+
if (typeof value.reviewer !== "string" || !value.reviewer.trim()) problems.push("has no reviewer");
|
|
52
|
+
if (!Array.isArray(value.findings)) problems.push("must record findings as an array");
|
|
53
|
+
if (!Array.isArray(value.required_fixes)) problems.push("must record required_fixes as an array");
|
|
54
|
+
if (!Array.isArray(value.checked_against) || value.checked_against.length === 0) {
|
|
55
|
+
problems.push("must record checked_against as a non-empty array naming what the review read");
|
|
56
|
+
}
|
|
57
|
+
if (problems.length > 0) throw new Error(`review '${ref}' ${problems.join("; ")}`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isApproving(verdict) {
|
|
62
|
+
return APPROVING_VERDICTS.includes(String(verdict).toUpperCase());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Attack 3: the review must have judged the commit that is about to be consumed.
|
|
66
|
+
// Comparing to the slice's current head rather than to anything the review says
|
|
67
|
+
// about itself is the point — a review cannot vouch for its own currency.
|
|
68
|
+
export function assertReviewBinding({ review, ref, observedHead, subject = null, attempt = null }) {
|
|
69
|
+
// Finding 2: a review was bound to a commit but not to a subject, so a valid
|
|
70
|
+
// approval for another slice at the same commit was accepted. With several slices
|
|
71
|
+
// in a wave and one --review-ref argument, passing the wrong one is an ordinary
|
|
72
|
+
// mistake. The record already names its subject, so checking costs nothing.
|
|
73
|
+
if (subject !== null && review.subject !== subject) {
|
|
74
|
+
throw new Error(`review '${ref}' approved '${review.subject}', not '${subject}'`);
|
|
75
|
+
}
|
|
76
|
+
if (attempt !== null && review.attempt !== attempt) {
|
|
77
|
+
throw new Error(`review '${ref}' is for attempt ${review.attempt}, subject is at attempt ${attempt}`);
|
|
78
|
+
}
|
|
79
|
+
if (!isApproving(review.verdict)) {
|
|
80
|
+
throw new Error(`review '${ref}' verdict is ${review.verdict}, not an approval`);
|
|
81
|
+
}
|
|
82
|
+
if (!SHA.test(String(observedHead))) {
|
|
83
|
+
throw new Error(`review '${ref}' cannot be consumed without an observed head`);
|
|
84
|
+
}
|
|
85
|
+
if (review.reviewed_commit !== observedHead) {
|
|
86
|
+
throw new Error(`review '${ref}' approved ${review.reviewed_commit.slice(0, 12)} but the head is ${String(observedHead).slice(0, 12)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The implementation-validator's judgement, bound the way every other judgement here is
|
|
91
|
+
// bound: by a record naming the commit it judged.
|
|
92
|
+
//
|
|
93
|
+
// `factory validator` took the verdict and head as arguments and stored them without
|
|
94
|
+
// reading anything, so a report describing H1 could be recorded as a verdict on H2 — which
|
|
95
|
+
// is exactly what happened, and it disproves the claim that the whole-diff pass stands
|
|
96
|
+
// between unreviewed content and a PR. It does, but only if the record says what it judged.
|
|
97
|
+
// Both values are now derived from that record, and the commit it names must be the head as
|
|
98
|
+
// observed now. `assertReviewBinding` is not reused: it demands an approving verdict, and a
|
|
99
|
+
// NO-GO must be recordable.
|
|
100
|
+
export function readValidatorReview(runDir, observedHead) {
|
|
101
|
+
const ref = reviewRef("implementation-validator");
|
|
102
|
+
const review = readReview(runDir, ref);
|
|
103
|
+
if (review.subject !== "implementation-validator") {
|
|
104
|
+
throw new Error(`${ref} describes '${review.subject}', not the implementation-validator`);
|
|
105
|
+
}
|
|
106
|
+
if (!VALIDATOR_VERDICTS.includes(review.verdict)) {
|
|
107
|
+
throw new Error(`${ref} verdict must be one of ${VALIDATOR_VERDICTS.join(" | ")}`);
|
|
108
|
+
}
|
|
109
|
+
if (!SHA.test(String(observedHead))) {
|
|
110
|
+
throw new Error(`${ref} cannot be recorded without an observed integration head`);
|
|
111
|
+
}
|
|
112
|
+
if (review.reviewed_commit !== observedHead) {
|
|
113
|
+
throw new Error(`${ref} judged ${review.reviewed_commit.slice(0, 12)} but the integration head is ${String(observedHead).slice(0, 12)}; re-run the validator`);
|
|
114
|
+
}
|
|
115
|
+
return review;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Attack 2: the merge proof. What the merge contributed must be exactly what was
|
|
119
|
+
// reviewed.
|
|
120
|
+
//
|
|
121
|
+
// This was tree equality — reviewed_tree === merged_tree — which is wrong for the
|
|
122
|
+
// design's central case. A wave's slices all branch from the same integration head and
|
|
123
|
+
// merges are serial, so the second merge of any wave lands on a base containing the
|
|
124
|
+
// first slice's work and its merged tree necessarily differs from what was reviewed.
|
|
125
|
+
// Every multi-slice wave would have failed on its second merge; a single-slice fixture
|
|
126
|
+
// hid it.
|
|
127
|
+
//
|
|
128
|
+
// Diff equality instead, expressed as changed-path sets rather than by comparing patch
|
|
129
|
+
// text (patch context can differ legitimately once the surrounding base has moved):
|
|
130
|
+
//
|
|
131
|
+
// * the paths the merge touched, relative to its first parent, are exactly the paths
|
|
132
|
+
// the slice changed relative to its own base; and
|
|
133
|
+
// * the reviewed commit and the merge differ on none of those paths.
|
|
134
|
+
//
|
|
135
|
+
// A moved base is then normal and passes. Unreviewed content inside the merge shows up
|
|
136
|
+
// either as an extra path or as a differing blob, and fails. Tree equality is the
|
|
137
|
+
// special case where the first parent still equals base_ref, i.e. a wave's first merge.
|
|
138
|
+
//
|
|
139
|
+
// Dependencies need no special handling: a wave contains only slices whose dependencies
|
|
140
|
+
// are already merged, so a dependent's base already includes them and its reviewed diff
|
|
141
|
+
// is just its own change. The only base movement the proof must tolerate is a same-wave
|
|
142
|
+
// sibling merging first.
|
|
143
|
+
export function observeMergeProof(worktree, { baseRef, reviewedCommit, mergeCommit, options = {} } = {}) {
|
|
144
|
+
const fail = (reason, extra = {}) => ({ proven: false, reason, ...extra });
|
|
145
|
+
|
|
146
|
+
const ancestry = observeAncestry(worktree, reviewedCommit, mergeCommit, options);
|
|
147
|
+
if (ancestry !== "ancestor") return fail(`reviewed commit is ${ancestry} of the merge commit`);
|
|
148
|
+
|
|
149
|
+
// Exactly two parents, so first-parent means "the integration branch before this
|
|
150
|
+
// merge" by construction rather than by assumption. A fast-forward has one parent -
|
|
151
|
+
// the slice's own previous commit - and the proof would then measure the slice's last
|
|
152
|
+
// commit against its whole reviewed diff, silently checking the wrong thing. Proving a
|
|
153
|
+
// fast-forward would need the pre-merge head stored as a durable field; requiring
|
|
154
|
+
// --no-ff, which the skill already specifies, costs nothing. An octopus merge carries
|
|
155
|
+
// other branches, which would surface as unreviewed paths and misdescribe the cause.
|
|
156
|
+
const parents = revList(worktree, mergeCommit, options);
|
|
157
|
+
if (parents === null) return fail("the merge commit's parents could not be observed");
|
|
158
|
+
if (parents.length !== 2) {
|
|
159
|
+
return fail(`the merge commit has ${parents.length} parent${parents.length === 1 ? "" : "s"}; a slice merge must be a two-parent merge (use --no-ff)`);
|
|
160
|
+
}
|
|
161
|
+
const firstParent = parents[0];
|
|
162
|
+
|
|
163
|
+
const reviewedPaths = pathsChanged(worktree, baseRef, reviewedCommit, options);
|
|
164
|
+
const contributedPaths = pathsChanged(worktree, firstParent, mergeCommit, options);
|
|
165
|
+
if (reviewedPaths === null || contributedPaths === null) {
|
|
166
|
+
return fail("changed paths could not be observed");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const extra = contributedPaths.filter((path) => !reviewedPaths.includes(path));
|
|
170
|
+
if (extra.length > 0) {
|
|
171
|
+
return fail(`the merge contributed paths that were not reviewed: ${extra.join(", ")}`);
|
|
172
|
+
}
|
|
173
|
+
const missing = reviewedPaths.filter((path) => !contributedPaths.includes(path));
|
|
174
|
+
if (missing.length > 0) {
|
|
175
|
+
return fail(`the merge did not contribute reviewed paths: ${missing.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Do not add a check that the base moved only by *recorded* slice merges. It was built
|
|
179
|
+
// and reverted: `base_ref` is immutable, so refusing the merge permanently strands the
|
|
180
|
+
// slice and the run ships nothing — a whole feature destroyed to enforce a lane check.
|
|
181
|
+
// It also contradicts SKILL.md, whose NO-GO remediation permits fixing test-only problems
|
|
182
|
+
// directly in the integration branch. Unreviewed content is stopped downstream instead,
|
|
183
|
+
// where it costs an approval rather than the run: the validator judges the whole
|
|
184
|
+
// integrated diff, and publication requires its judged head to still be the head.
|
|
185
|
+
|
|
186
|
+
// Content identity, asked as one diff rather than as a per-path lookup.
|
|
187
|
+
//
|
|
188
|
+
// This was a per-path `ls-tree` comparison, and it could not distinguish two cases that
|
|
189
|
+
// both look like an empty lookup: the path is absent because the slice deleted it and
|
|
190
|
+
// the merge deleted it too, which agrees; or the pathspec matched nothing because the
|
|
191
|
+
// name was mis-parsed, which is tampered content wrongly proven. Treating them the same
|
|
192
|
+
// either passed the tampering or, once that was refused, refused every slice that
|
|
193
|
+
// deletes a file. Both spellings were wrong because the question was asked in a form
|
|
194
|
+
// whose failure mode is silence.
|
|
195
|
+
//
|
|
196
|
+
// Asked as a diff, there is no lookup to miss. Any difference on a reviewed path —
|
|
197
|
+
// content, file mode, or a regular file becoming a symlink — appears as that path in
|
|
198
|
+
// the drift list, and a deletion both commits made appears in neither. Both lists come
|
|
199
|
+
// from the same parser, so a mis-parsed name compares as the same wrong string on both
|
|
200
|
+
// sides and still matches.
|
|
201
|
+
//
|
|
202
|
+
// Drift on a path *outside* reviewedPaths is the sibling-merge case this proof must
|
|
203
|
+
// tolerate: such a path is identical in base and reviewed, and the two-parent check
|
|
204
|
+
// above already proved the merge contributed nothing outside reviewedPaths, so the
|
|
205
|
+
// difference came from the integration branch rather than from this slice.
|
|
206
|
+
const drift = pathsChanged(worktree, reviewedCommit, mergeCommit, options);
|
|
207
|
+
if (drift === null) return fail("the reviewed commit could not be compared to the merge");
|
|
208
|
+
const altered = drift.filter((path) => reviewedPaths.includes(path));
|
|
209
|
+
if (altered.length > 0) {
|
|
210
|
+
return fail(`the merge's content differs from the reviewed commit's: ${altered.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { proven: true, reason: null, reviewed_paths: reviewedPaths, first_parent: firstParent };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function revList(worktree, commit, options) {
|
|
217
|
+
const probe = git(worktree, ["rev-list", "--parents", "-n", "1", commit], options);
|
|
218
|
+
if (!probe.ok) return null;
|
|
219
|
+
// "<commit> <parent1> <parent2>..." — drop the commit itself.
|
|
220
|
+
const fields = probe.stdout.trim().split(/\s+/u).filter(Boolean);
|
|
221
|
+
return fields.length > 0 ? fields.slice(1) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pathsChanged(worktree, from, to, options) {
|
|
225
|
+
// -z, and no trimming: a filename may legitimately contain a newline, a tab, or
|
|
226
|
+
// leading and trailing spaces, and trimming one turned a real path into a pathspec
|
|
227
|
+
// that matched nothing.
|
|
228
|
+
const probe = git(worktree, ["--literal-pathspecs", "diff", "--name-only", "-z", from, to], options);
|
|
229
|
+
if (!probe.ok) return null;
|
|
230
|
+
return probe.stdout.split("\0").filter((path) => path !== "").sort();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The single definition of "this run may be published", asked at both points where that
|
|
234
|
+
// question has an answer: when Gate 3 is approved, and again when the PR is recorded.
|
|
235
|
+
//
|
|
236
|
+
// It lived only in the `pr` handler, and the skill pushes the branch and creates the PR
|
|
237
|
+
// *before* calling `factory pr` — so every check there was post-effect. It could describe
|
|
238
|
+
// a bad publication; it could not stop one. Gate 3's approval is the last transition
|
|
239
|
+
// before the push, so that is where the refusal has to be able to land. Asking again at
|
|
240
|
+
// `pr` is not redundant: between the two, slices can regress and the integration head can
|
|
241
|
+
// move, and the second call re-observes rather than trusting the first.
|
|
242
|
+
//
|
|
243
|
+
// `observeHead` is injected so this module does not need to know how a worktree is
|
|
244
|
+
// resolved; it returns the integration branch's currently observed commit, or null.
|
|
245
|
+
export function assertPublicationReady({ runDir, state, runId, repo, observeHead }) {
|
|
246
|
+
const refuse = (message) => { throw new Error(`this run is not publishable: ${message}`); };
|
|
247
|
+
|
|
248
|
+
if (state.status === "needs-human") {
|
|
249
|
+
refuse("a needs-human run is parked; run factory resume before publication");
|
|
250
|
+
}
|
|
251
|
+
if (TERMINAL_STATUSES.includes(state.status)) {
|
|
252
|
+
refuse(`a ${state.status} run must be surfaced, not published`);
|
|
253
|
+
}
|
|
254
|
+
// Every gate, currently approved — not just pre_pr, and not "was approved once".
|
|
255
|
+
// Re-opening a decided gate is what keeps a late change from stranding a run, but it also
|
|
256
|
+
// means an approval can be withdrawn, and publication was reading only pre_pr: opencode
|
|
257
|
+
// approved Gate 3, re-opened Story, decided stop, and recorded a PR against a stopped
|
|
258
|
+
// run. Asking for the current status of all three is what makes re-opening safe.
|
|
259
|
+
const unapproved = GATE_NAMES.filter((name) => state.gates?.[name]?.status !== "approved");
|
|
260
|
+
if (unapproved.length > 0) {
|
|
261
|
+
refuse(`every gate must be approved; not approved: ${unapproved.map((name) => `${name}(${state.gates?.[name]?.status ?? "absent"})`).join(", ")}`);
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(state.slices) || state.slices.length === 0) refuse("no slice plan has been seeded");
|
|
264
|
+
const unmerged = state.slices.filter((slice) => slice.status !== "merged");
|
|
265
|
+
if (unmerged.length > 0) {
|
|
266
|
+
refuse(`every slice must be merged; not merged: ${unmerged.map((slice) => `${slice.id}(${slice.status})`).join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
// Attack 4: the verdict names the head it judged, and that head is re-observed here
|
|
269
|
+
// rather than read back from the manifest — the manifest records what we were told and
|
|
270
|
+
// the repository records what is true.
|
|
271
|
+
const head = observeHead();
|
|
272
|
+
if (!head) refuse("the integration head could not be observed");
|
|
273
|
+
// Required only when there is something holistic to judge: the validator's subject is the diff
|
|
274
|
+
// *across* slices, and one slice has none — it re-reads what the slice reviewer just approved,
|
|
275
|
+
// serialized before the gate. Zero slices was refused above, so this is not "skip when nothing
|
|
276
|
+
// was built". Skipping is permitted, ignoring is not: a recorded verdict must approve and must
|
|
277
|
+
// name this head either way. The published head stays bound by the test-verifier check below.
|
|
278
|
+
const validator = state.validator;
|
|
279
|
+
if (!validator && state.slices.length > 1) refuse("a multi-slice run requires an approving validator verdict");
|
|
280
|
+
if (validator && !isApproving(validator.verdict)) refuse("the validator verdict is not an approval");
|
|
281
|
+
if (validator && head !== validator.reviewed_head) {
|
|
282
|
+
refuse(`the validator judged ${String(validator.reviewed_head).slice(0, 12)} but the integration head is ${head.slice(0, 12)}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let repair;
|
|
286
|
+
try {
|
|
287
|
+
repair = assertRepairPublicationReady({ runDir, state, runId, repo, head });
|
|
288
|
+
} catch (error) {
|
|
289
|
+
refuse(error.message);
|
|
290
|
+
}
|
|
291
|
+
if (repair.tested === head) return { head, tested: repair.tested };
|
|
292
|
+
|
|
293
|
+
// The test-verifier stage, required by evidence rather than by having been mentioned.
|
|
294
|
+
// Read at its canonical path, not through a ref in run.json: a ref is a value the
|
|
295
|
+
// orchestrator chooses, and the point is that this particular stage ran.
|
|
296
|
+
//
|
|
297
|
+
// `review_ready` is not sufficient here. It admits an explicitly-reasoned skip, which
|
|
298
|
+
// is right for a slice whose gate waived tests and wrong for the stage whose entire
|
|
299
|
+
// job is to run them. So the run is required to have been observed, and to have exited
|
|
300
|
+
// zero, with no exemption available.
|
|
301
|
+
const ref = evidenceRef("test-verifier");
|
|
302
|
+
const evidence = readEvidence(runDir, ref, { runId });
|
|
303
|
+
if (evidence.subject !== "test-verifier") refuse(`${ref} describes '${evidence.subject}'`);
|
|
304
|
+
// The concrete facts before the derived one, so each refusal names what is actually
|
|
305
|
+
// wrong. Reversed, `review_ready` absorbs both: a failing run derives false, so the
|
|
306
|
+
// exit-code branch could never be reached and would have been a dead guard reading as
|
|
307
|
+
// enforcement. The observed-run branch stays load-bearing either way — `review_ready`
|
|
308
|
+
// admits a recorded skip, which is exactly the exemption this stage does not get.
|
|
309
|
+
if (evidence.tests?.observed !== true) refuse(`${ref} records no observed test run`);
|
|
310
|
+
if (evidence.tests.exit !== 0) refuse(`${ref} records tests exiting ${evidence.tests.exit}`);
|
|
311
|
+
if (evidence.review_ready !== true) {
|
|
312
|
+
refuse(`${ref} is not review_ready${evidence.blocked_reason ? `: ${evidence.blocked_reason}` : ""}`);
|
|
313
|
+
}
|
|
314
|
+
if (evidence.commit !== head) {
|
|
315
|
+
refuse(`${ref} tested ${String(evidence.commit).slice(0, 12)} but the integration head is ${head.slice(0, 12)}`);
|
|
316
|
+
}
|
|
317
|
+
return { head, tested: evidence.commit };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Finding 2, three parts:
|
|
321
|
+
//
|
|
322
|
+
// * the ref was an unrestricted string joined to runDir, so ../app-2/evidence/... in
|
|
323
|
+
// another run's directory was accepted. Refs are admitted canonically: run-local, no
|
|
324
|
+
// traversal, under evidence/.
|
|
325
|
+
// * evidence carried no run identity, so a foreign record with a matching subject
|
|
326
|
+
// passed. run_id is required and must match.
|
|
327
|
+
// * review_ready was trusted as stored, so a record claiming true with tests.exit 1 was
|
|
328
|
+
// accepted. It is a *derived* field, so it is recomputed here from the record's own
|
|
329
|
+
// contents and the stored value must agree. A derived field read back as authority is
|
|
330
|
+
// not evidence, it is an assertion.
|
|
331
|
+
export function readEvidence(runDir, ref, { runId = null } = {}) {
|
|
332
|
+
if (typeof ref !== "string" || !ref.trim()) throw new Error("evidence ref is missing");
|
|
333
|
+
if (isAbsolute(ref) || ref.split(/[\\/]/u).includes("..")) {
|
|
334
|
+
throw new Error(`evidence ref '${ref}' must be run-local without traversal`);
|
|
335
|
+
}
|
|
336
|
+
const normalized = ref.split(sep).join("/");
|
|
337
|
+
if (!normalized.startsWith(`${EVIDENCE_DIR}/`)) {
|
|
338
|
+
throw new Error(`evidence ref '${ref}' must be under ${EVIDENCE_DIR}/`);
|
|
339
|
+
}
|
|
340
|
+
let value;
|
|
341
|
+
try {
|
|
342
|
+
value = JSON.parse(readFileSync(join(runDir, normalized), "utf8"));
|
|
343
|
+
} catch (error) {
|
|
344
|
+
throw new Error(`evidence '${ref}' could not be read: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
const unknown = Object.keys(value).filter((key) => !EVIDENCE_KEYS.includes(key));
|
|
347
|
+
if (unknown.length > 0) throw new Error(`evidence '${ref}' has unknown keys: ${unknown.sort().join(", ")}`);
|
|
348
|
+
for (const key of ["subject", "status", "observed_by"]) {
|
|
349
|
+
if (typeof value[key] !== "string" || !value[key].trim()) throw new Error(`evidence '${ref}' has no ${key}`);
|
|
350
|
+
}
|
|
351
|
+
if (value.observed_by !== "orchestrator") throw new Error(`evidence '${ref}' was not written by the orchestrator`);
|
|
352
|
+
if (typeof value.review_ready !== "boolean") throw new Error(`evidence '${ref}' has no review_ready`);
|
|
353
|
+
if (!Number.isSafeInteger(value.attempt) || value.attempt < 1) throw new Error(`evidence '${ref}' has no attempt`);
|
|
354
|
+
if (runId !== null && value.run_id !== runId) {
|
|
355
|
+
throw new Error(`evidence '${ref}' belongs to run '${value.run_id}', not '${runId}'`);
|
|
356
|
+
}
|
|
357
|
+
const derived = deriveReviewReady(value);
|
|
358
|
+
if (value.review_ready !== derived) {
|
|
359
|
+
throw new Error(`evidence '${ref}' claims review_ready: ${value.review_ready} but its own contents derive ${derived}`);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "feature-factory",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Durable, observed control plane for /feature runs. Host-agnostic: no opencode dependency.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jasoncarreira/feature-factory.git",
|
|
10
|
+
"directory": "packages/feature-factory"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"factory": "bin/factory.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "state/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./state/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"core",
|
|
22
|
+
"observe",
|
|
23
|
+
"state",
|
|
24
|
+
"WORKFLOW.md",
|
|
25
|
+
"agents",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --import ../../tools/resolve-guard.mjs --test test/*.test.js"
|
|
34
|
+
}
|
|
35
|
+
}
|