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.
@@ -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
+ }