muse-crew 0.14.4 → 0.14.6
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/AGENTS.md +1 -1
- package/API.md +61 -7
- package/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +244 -1
- package/docs/guide.md +42 -6
- package/docs/publish-unknown-recovery.md +28 -113
- package/docs/publish-verification.md +148 -445
- package/docs/release-integrity.md +5 -3
- package/docs/reviews/critic-0145.md +106 -0
- package/lib/AGENTS.md +4 -8
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +113 -15
- package/lib/crew-api.js +855 -725
- package/lib/publish-note-vocabulary.js +137 -24
- package/lib/schema.sql +19 -0
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +65 -65
- package/seed/crons.json +12 -0
- package/workflows/bugfix.js +146 -740
- package/workflows/chore.js +146 -740
- package/workflows/crew-dispatch.js +36 -0
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/standard.js +146 -763
- package/lib/build-readback-request.js +0 -140
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -405
- package/lib/verify-publish.js +0 -414
package/lib/verify-publish.js
DELETED
|
@@ -1,414 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// verify-publish.js — deterministic parent publish verifier.
|
|
3
|
-
//
|
|
4
|
-
// The parent never eyeballs an inspection report. This script takes the
|
|
5
|
-
// read-back inspection's result JSON and MECHANICALLY decides the verdict:
|
|
6
|
-
// it parses the machine-readable findings block, compares every
|
|
7
|
-
// added/removed diff line against the reported present/absent verdicts
|
|
8
|
-
// (with mechanically computed collision exemptions for lines that also
|
|
9
|
-
// occur in untouched code and therefore have zero discriminating power),
|
|
10
|
-
// checks supersession via git, and only then stamps provenance, logs the
|
|
11
|
-
// terminal event, and re-queues the task.
|
|
12
|
-
//
|
|
13
|
-
// The sensor is the deterministic lib/readback-disk.js (it reads the
|
|
14
|
-
// on-disk tree the artifact is built/served from); this code is the judge.
|
|
15
|
-
// The LLM inspector driven by lib/build-readback-request.js is retained
|
|
16
|
-
// only as the manual fallback for environments without disk access.
|
|
17
|
-
// Unparseable findings, content mismatches, unverifiable changes (binary
|
|
18
|
-
// files, mode-only changes, renames, fully-colliding hunks), supersession,
|
|
19
|
-
// and stamp failures all fail CLOSED with a terminal parent verdict —
|
|
20
|
-
// never a stamp.
|
|
21
|
-
//
|
|
22
|
-
// Usage:
|
|
23
|
-
// node verify-publish.js --crew-home <path> --task-id <uuid>
|
|
24
|
-
// --commit <sha> --base <sha> --repo-path <path> --slug <slug>
|
|
25
|
-
// --crew-release <release> --inspection-id <uuid> --result-file <path>
|
|
26
|
-
// --project-id <project-id> [--build-agent-id <uuid>]
|
|
27
|
-
//
|
|
28
|
-
// --base is the previously-stamped provenance source_commit (or the
|
|
29
|
-
// empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
|
|
30
|
-
// publish). It MUST be the same base the read-back request was built
|
|
31
|
-
// with: the verifier checks the inspector's findings against the
|
|
32
|
-
// base..commit diff, and a mismatched base would compare the findings
|
|
33
|
-
// against the wrong expected change. See the --base contract in
|
|
34
|
-
// lib/build-readback-request.js (push-time reconcile merges break the
|
|
35
|
-
// old commit^1 assumption, 2026-09-14, task 0c53af4e).
|
|
36
|
-
//
|
|
37
|
-
// Exit codes: 0 verified · 1 verification failed (terminal verdict logged) ·
|
|
38
|
-
// 2 usage/validation.
|
|
39
|
-
|
|
40
|
-
import { execFileSync } from "node:child_process";
|
|
41
|
-
import { readFileSync } from "node:fs";
|
|
42
|
-
import { homedir } from "node:os";
|
|
43
|
-
import { join } from "node:path";
|
|
44
|
-
// Shared content-judgment primitives (2026-09-18, blocker 15): the same
|
|
45
|
-
// diff parser, findings parser, and collision-exemption rules the
|
|
46
|
-
// unknown-recovery classifier uses — one judgment, never two copies.
|
|
47
|
-
import {
|
|
48
|
-
EMPTY_TREE,
|
|
49
|
-
parseDiff,
|
|
50
|
-
parseFindings,
|
|
51
|
-
makeOldCounter,
|
|
52
|
-
discriminatingLines,
|
|
53
|
-
} from "./publish-content.js";
|
|
54
|
-
// Terminal-note vocabulary (D7, 2026-09-19): the writer-side guard.
|
|
55
|
-
// terminal() asserts its emitted verb is a registry member before writing,
|
|
56
|
-
// so no future terminal verb ships unrecognized by the scans.
|
|
57
|
-
import { matchTerminalPublishNote } from "./publish-note-vocabulary.js";
|
|
58
|
-
|
|
59
|
-
// --help: before required-arg parsing (shebang⇔CLI contract).
|
|
60
|
-
if (process.argv.slice(2).includes("--help")) {
|
|
61
|
-
console.log(
|
|
62
|
-
"usage: node verify-publish.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> --project-id <id> [--spaces-root <dir>]\n" +
|
|
63
|
-
"the mechanical publish verification JUDGE: certifies read-back findings against git diff, checks manifest freshness, stamps provenance"
|
|
64
|
-
);
|
|
65
|
-
process.exit(0);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function arg(name, required = true) {
|
|
69
|
-
const i = process.argv.indexOf(name);
|
|
70
|
-
if (i < 0 || i + 1 >= process.argv.length) {
|
|
71
|
-
if (!required) return null;
|
|
72
|
-
fail("usage", `${name} is required.`, 2);
|
|
73
|
-
}
|
|
74
|
-
return process.argv[i + 1];
|
|
75
|
-
}
|
|
76
|
-
function fail(code, message, exitCode) {
|
|
77
|
-
process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
|
|
78
|
-
process.exit(exitCode);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const crewHome = arg("--crew-home");
|
|
82
|
-
const taskId = arg("--task-id");
|
|
83
|
-
const commit = arg("--commit");
|
|
84
|
-
const base = arg("--base");
|
|
85
|
-
const repoPath = arg("--repo-path");
|
|
86
|
-
const slug = arg("--slug");
|
|
87
|
-
const inspectionId = arg("--inspection-id");
|
|
88
|
-
const resultFile = arg("--result-file");
|
|
89
|
-
const crewRelease = arg("--crew-release");
|
|
90
|
-
const buildAgentId = arg("--build-agent-id", false);
|
|
91
|
-
const projectId = arg("--project-id");
|
|
92
|
-
const attempt = arg("--attempt", false); // 2026-09-18, blocker 4: exact attempt binding
|
|
93
|
-
const spacesRoot = arg("--spaces-root", false) || join(homedir(), "workspace", "ts-spaces");
|
|
94
|
-
|
|
95
|
-
if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
|
|
96
|
-
if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
|
|
97
|
-
|
|
98
|
-
const crewApi = join(crewHome, "lib", "crew-api.js");
|
|
99
|
-
function api(command, json) {
|
|
100
|
-
const out = execFileSync("node", [crewApi, "--crew-home", crewHome, command, "--json", JSON.stringify(json)], {
|
|
101
|
-
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
102
|
-
});
|
|
103
|
-
return JSON.parse(out);
|
|
104
|
-
}
|
|
105
|
-
// The terminal verdict this writer emits. A single const (not an inline
|
|
106
|
-
// literal) so the emitted verb and the asserted verb can never drift apart.
|
|
107
|
-
const TERMINAL_VERB = "publish: verification-failed";
|
|
108
|
-
function terminal(reason, detail) {
|
|
109
|
-
// Always lands a terminal parent verdict, then exits 1 (failed) — the
|
|
110
|
-
// task stays parked for human attention. Only the verified path exits 0.
|
|
111
|
-
// Writer-side registry guard (D7): the verb is in the terminal-note
|
|
112
|
-
// registry before the note is written — a writer can never introduce a
|
|
113
|
-
// terminal verb the scans don't recognize.
|
|
114
|
-
if (!matchTerminalPublishNote(TERMINAL_VERB)) {
|
|
115
|
-
// Registry and writer disagree — fail loud, never emit an unrecognized
|
|
116
|
-
// terminal note.
|
|
117
|
-
process.stderr.write(JSON.stringify({
|
|
118
|
-
ok: false, verdict: "vocabulary-mismatch",
|
|
119
|
-
message: `terminal verb '${TERMINAL_VERB}' is not in the terminal-note registry`,
|
|
120
|
-
}) + "\n");
|
|
121
|
-
process.exit(2);
|
|
122
|
-
}
|
|
123
|
-
const msg = `${TERMINAL_VERB} ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
|
|
124
|
-
api("log-event", { task_id: taskId, type: "note", message: msg.slice(0, 1000) });
|
|
125
|
-
process.stderr.write(JSON.stringify({ ok: false, verdict: reason, message: msg }) + "\n");
|
|
126
|
-
process.exit(1);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// --- 1. Load the inspection result and extract the findings text ---------
|
|
130
|
-
// The tick worker saves the FULL async result — which may be the inspector's
|
|
131
|
-
// raw prose or a JSON envelope wrapping it (the platform's async handoff
|
|
132
|
-
// shape). The findings block is located mechanically, never assumed:
|
|
133
|
-
// - raw text (not JSON): the whole file is the one candidate.
|
|
134
|
-
// - JSON: every string leaf is a candidate (depth-first, encounter order),
|
|
135
|
-
// recursing into double-encoded JSON strings (bounded depth).
|
|
136
|
-
// The winning candidate is the one whose findings cover the most files from
|
|
137
|
-
// the expected diff — so an echoed request template or stray prose never
|
|
138
|
-
// outranks the real findings block. No candidate with real paths =>
|
|
139
|
-
// unreadable-result, fail closed.
|
|
140
|
-
let resultText;
|
|
141
|
-
try {
|
|
142
|
-
resultText = readFileSync(resultFile, "utf8");
|
|
143
|
-
} catch (e) {
|
|
144
|
-
fail("usage", `cannot read result file: ${e.message}`, 2);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function collectCandidates(value, depth, out) {
|
|
148
|
-
if (depth > 4 || out.length > 64) return;
|
|
149
|
-
if (typeof value === "string") {
|
|
150
|
-
out.push(value);
|
|
151
|
-
const t = value.trim();
|
|
152
|
-
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
153
|
-
try { collectCandidates(JSON.parse(t), depth + 1, out); } catch { /* not JSON */ }
|
|
154
|
-
}
|
|
155
|
-
} else if (Array.isArray(value)) {
|
|
156
|
-
for (const v of value) collectCandidates(v, depth, out);
|
|
157
|
-
} else if (value && typeof value === "object") {
|
|
158
|
-
for (const v of Object.values(value)) collectCandidates(v, depth, out);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let candidates;
|
|
163
|
-
try {
|
|
164
|
-
const parsed = JSON.parse(resultText);
|
|
165
|
-
candidates = [];
|
|
166
|
-
collectCandidates(parsed, 0, candidates);
|
|
167
|
-
if (candidates.length === 0) candidates = [resultText];
|
|
168
|
-
} catch {
|
|
169
|
-
candidates = [resultText];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// The inspector was instructed to emit a machine-readable block:
|
|
173
|
-
// FILE: <path>
|
|
174
|
-
// ADDED: <line> :: PRESENT|ABSENT
|
|
175
|
-
// REMOVED: <line> :: PRESENT|ABSENT
|
|
176
|
-
// END_FILE
|
|
177
|
-
// (parsed by the shared parseFindings in lib/publish-content.js)
|
|
178
|
-
|
|
179
|
-
// --- 2. Expected diff from git (never from the builder's report) -----------
|
|
180
|
-
// The expected change is the publish delta base..commit, using the SAME
|
|
181
|
-
// base the read-back request was built with. A base that is not an
|
|
182
|
-
// ancestor of commit is a usage error (exit 2, no terminal verdict — this
|
|
183
|
-
// is a procedural input problem, not a content failure).
|
|
184
|
-
if (base !== EMPTY_TREE) {
|
|
185
|
-
const anc = (() => { try {
|
|
186
|
-
execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
|
|
187
|
-
return true;
|
|
188
|
-
} catch { return false; } })();
|
|
189
|
-
if (!anc) fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to verify against an unrelated tree.`, 2);
|
|
190
|
-
}
|
|
191
|
-
let diff;
|
|
192
|
-
try {
|
|
193
|
-
diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
|
|
194
|
-
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
195
|
-
});
|
|
196
|
-
} catch (e) {
|
|
197
|
-
terminal("read-back-unavailable", `git diff failed: ${e.message}`);
|
|
198
|
-
}
|
|
199
|
-
const expected = parseDiff(diff); // path -> { added: [], removed: [], isBinary: bool }
|
|
200
|
-
|
|
201
|
-
// --- 3. Pick the findings candidate that covers the expected diff -----------
|
|
202
|
-
let findings = null;
|
|
203
|
-
let bestScore = -1;
|
|
204
|
-
let malformedNote = null;
|
|
205
|
-
for (const cand of candidates) {
|
|
206
|
-
const { findings: f, malformed } = parseFindings(cand);
|
|
207
|
-
if (f.size === 0) continue;
|
|
208
|
-
let score = 0;
|
|
209
|
-
for (const p of f.keys()) if (expected.has(p)) score++;
|
|
210
|
-
if (malformed && score > 0) malformedNote = malformed;
|
|
211
|
-
if (score > bestScore) { bestScore = score; findings = f; }
|
|
212
|
-
}
|
|
213
|
-
if (malformedNote && findings) terminal("unreadable-result", malformedNote);
|
|
214
|
-
if (!findings || bestScore <= 0) {
|
|
215
|
-
terminal("unreadable-result", "no machine-readable FILE blocks covering the expected diff in inspection result");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// --- 4. Mechanical comparison ----------------------------------------------
|
|
219
|
-
// Collision exemption (2026-09-15, task 00bca4b8): a removed diff line that
|
|
220
|
-
// also occurs verbatim in untouched code has zero discriminating power —
|
|
221
|
-
// its presence in the new file cannot tell "old block removed" from "old
|
|
222
|
-
// block present". Task 1d692d91 parked a valid publish because two removed
|
|
223
|
-
// lines (` return (`, ` </div>`) occur identically in
|
|
224
|
-
// the untouched WorkflowSteps component; the naive every-removed-line-ABSENT
|
|
225
|
-
// rule failed closed on zero signal.
|
|
226
|
-
//
|
|
227
|
-
// The exemption is mechanically computed and signal-preserving: for file F,
|
|
228
|
-
// line L is exempt iff L occurs in F's old tree (at <base>) strictly more
|
|
229
|
-
// times than the expected diff removes it. An exempted line must survive in
|
|
230
|
-
// untouched code no matter what, so exempting it can never turn a missed
|
|
231
|
-
// removal into a pass. No change to the sensor: build-readback-request.js
|
|
232
|
-
// still reports whole-file PRESENT/ABSENT honestly; only this judge gets
|
|
233
|
-
// smarter.
|
|
234
|
-
// Collision exemption (2026-09-15, task 00bca4b8) — decided once by the
|
|
235
|
-
// shared lib/publish-content.js so the verifier and the unknown-recovery
|
|
236
|
-
// classifier can never disagree about what a diff proves:
|
|
237
|
-
//
|
|
238
|
-
// - a removed diff line that also occurs verbatim in untouched code has
|
|
239
|
-
// zero discriminating power — its presence in the new file cannot tell
|
|
240
|
-
// "old block removed" from "old block present";
|
|
241
|
-
// - an added line that already occurs in the old tree is reported PRESENT
|
|
242
|
-
// whether or not the new hunk actually landed — same zero power.
|
|
243
|
-
//
|
|
244
|
-
// Exempting a line can never turn a missed change into a pass: an exempted
|
|
245
|
-
// line must survive in untouched code no matter what. No change to the
|
|
246
|
-
// sensor: build-readback-request.js still reports whole-file PRESENT/ABSENT
|
|
247
|
-
// honestly; only this judge gets smarter. The occurrence counts are
|
|
248
|
-
// computed from `git show <base>:<path>` (never the working tree) by the
|
|
249
|
-
// shared makeOldCounter; the diff may remove the same line more than once
|
|
250
|
-
// and the budget accounting lives in the shared discriminatingLines.
|
|
251
|
-
const oldCount = makeOldCounter(repoPath, base);
|
|
252
|
-
const disc = discriminatingLines(expected, oldCount);
|
|
253
|
-
const discAdded = new Map(); // path -> Set(line): discriminating added lines
|
|
254
|
-
const discRemoved = new Map(); // path -> Set(line): discriminating removed lines
|
|
255
|
-
for (const [p, d] of disc) {
|
|
256
|
-
discAdded.set(p, new Set(d.addedDisc));
|
|
257
|
-
discRemoved.set(p, new Set(d.removedDisc));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
let exempted = 0;
|
|
261
|
-
for (const [path, exp] of expected) {
|
|
262
|
-
const found = findings.get(path);
|
|
263
|
-
if (!found) terminal("content-mismatch", `no findings for changed file ${path}`);
|
|
264
|
-
// (2026-09-16, critic finding 1) Binary files, mode-only changes, and
|
|
265
|
-
// renames produce zero added/removed lines: the loops below would
|
|
266
|
-
// iterate over empty arrays and the stamp would issue with "all added
|
|
267
|
-
// lines PRESENT, all removed lines ABSENT" — vacuously true, content
|
|
268
|
-
// never read. The line-based judge cannot verify these changes, so they
|
|
269
|
-
// fail closed as unverifiable (never stamped); a human verifies.
|
|
270
|
-
if (exp.added.length === 0 && exp.removed.length === 0) {
|
|
271
|
-
terminal("unverifiable-content",
|
|
272
|
-
`${exp.isBinary ? "binary file" : "no content lines (mode-only change or rename)"} ${path}: ` +
|
|
273
|
-
"the diff carries no added/removed lines for this file, so the line-based read-back checked nothing — " +
|
|
274
|
-
"provenance NOT stamped; human verification needed");
|
|
275
|
-
}
|
|
276
|
-
// (2026-09-16, critic finding 5) Added-line collisions: require at least
|
|
277
|
-
// one discriminating added line per file, or the added-side check is
|
|
278
|
-
// vacuous (finding 1's class).
|
|
279
|
-
let discriminatingAdded = 0;
|
|
280
|
-
for (const line of exp.added) {
|
|
281
|
-
const v = found.added.get(line);
|
|
282
|
-
if (v === undefined) terminal("unreadable-result", `no ADDED finding for line in ${path}: ${line.slice(0, 60)}`);
|
|
283
|
-
if (!discAdded.get(path).has(line)) {
|
|
284
|
-
exempted += 1; // colliding pre-existing line: zero signal, cannot fail a good publish
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
discriminatingAdded += 1;
|
|
288
|
-
if (v !== "PRESENT") terminal("content-mismatch", `added line ABSENT in ${path}: ${line.slice(0, 80)}`);
|
|
289
|
-
}
|
|
290
|
-
if (exp.added.length > 0 && discriminatingAdded === 0) {
|
|
291
|
-
terminal("unverifiable-content",
|
|
292
|
-
`all ${exp.added.length} added line(s) in ${path} already occur in the old tree: ` +
|
|
293
|
-
"their PRESENT findings cannot tell \"hunk landed\" from \"hunk dropped\" — " +
|
|
294
|
-
"provenance NOT stamped; human verification needed");
|
|
295
|
-
}
|
|
296
|
-
for (const line of exp.removed) {
|
|
297
|
-
const v = found.removed.get(line);
|
|
298
|
-
if (v === undefined) terminal("unreadable-result", `no REMOVED finding for line in ${path}: ${line.slice(0, 60)}`);
|
|
299
|
-
if (v === "ABSENT") continue;
|
|
300
|
-
if (!discRemoved.get(path).has(line)) {
|
|
301
|
-
exempted += 1; // colliding pre-existing line: zero signal, cannot fail a good publish
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
terminal("content-mismatch", `removed line PRESENT in ${path}: ${line.slice(0, 80)}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// --- 5. Supersession ---------------------------------------------------------
|
|
309
|
-
let head;
|
|
310
|
-
try {
|
|
311
|
-
head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
312
|
-
} catch (e) {
|
|
313
|
-
terminal("read-back-unavailable", `git rev-parse HEAD failed: ${e.message}`);
|
|
314
|
-
}
|
|
315
|
-
if (head !== commit) {
|
|
316
|
-
terminal("superseded", `HEAD is ${head}, not ${commit} — a newer publish supersedes this one`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// --- 5b. Manifest freshness (design §1.9) ------------------------------------
|
|
320
|
-
// The stamp certifies that a NEW build landed for this attempt — not a
|
|
321
|
-
// replayed manifest. The workflow snapshots the pre-trigger manifest into
|
|
322
|
-
// the submitted ledger entry; require (a) the current manifest's built_at
|
|
323
|
-
// advanced past the trigger, and (b) its content_sha256 differs from the
|
|
324
|
-
// pre-trigger baseline (a new build identity). A missing baseline fails
|
|
325
|
-
// closed — without it, freshness cannot be proven.
|
|
326
|
-
// 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
|
|
327
|
-
// attempt is known — the original and retry attempts share a commit, and
|
|
328
|
-
// positional selection is luck. Within one attempt, select the OLDEST
|
|
329
|
-
// submitted entry (the trigger issuance; a receipt-bearing second "submitted"
|
|
330
|
-
// may follow it — the oldest binds the trigger instant, the latest carries
|
|
331
|
-
// the observed agent_id).
|
|
332
|
-
// D1 (2026-09-19; entry_kind cut 2026-09-20): the trigger instant is the
|
|
333
|
-
// ISSUANCE instant, not the ledger-write instant — a slow ledger write must
|
|
334
|
-
// not make a fresh build look stale (room #23 J1). The anchor is the OLDEST
|
|
335
|
-
// submitted entry by ts and the bound instant is its issued_at (falling back
|
|
336
|
-
// to its ts when the capture was unobserved). One anchor, one field — the
|
|
337
|
-
// retired entry_kind preference filter could never change the bound anchor
|
|
338
|
-
// (issuance and receipt entries carried the same captured issued_at), so the
|
|
339
|
-
// oldest-by-ts selection is the whole mechanism.
|
|
340
|
-
let triggerTs = null;
|
|
341
|
-
let manifestBefore = null;
|
|
342
|
-
try {
|
|
343
|
-
const ledgerText = readFileSync(join(crewHome, ".publish-ledger", slug + ".jsonl"), "utf8");
|
|
344
|
-
const entries = ledgerText.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
345
|
-
// Collect all submitted entries matching (task_id, commit[, attempt]).
|
|
346
|
-
const candidates = entries.filter((e) =>
|
|
347
|
-
e.task_id === taskId &&
|
|
348
|
-
e.outcome === "submitted" &&
|
|
349
|
-
e.commit === commit &&
|
|
350
|
-
(attempt == null || e.attempt === attempt)
|
|
351
|
-
);
|
|
352
|
-
if (candidates.length > 0) {
|
|
353
|
-
candidates.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
354
|
-
const e = candidates[0];
|
|
355
|
-
triggerTs = e.issued_at || e.ts || null;
|
|
356
|
-
if (e.manifest_before && typeof e.manifest_before === "object") manifestBefore = e.manifest_before;
|
|
357
|
-
}
|
|
358
|
-
} catch (e) {
|
|
359
|
-
terminal("read-back-unavailable", `cannot read publish ledger for freshness check: ${e.message}`);
|
|
360
|
-
}
|
|
361
|
-
if (!triggerTs) {
|
|
362
|
-
terminal("read-back-unavailable", `no submitted ledger entry for commit ${commit} — the trigger instant is unknowable; freshness cannot be proven`);
|
|
363
|
-
}
|
|
364
|
-
if (!manifestBefore || typeof manifestBefore.content_sha256 !== "string") {
|
|
365
|
-
terminal("unverifiable-content", `no pre-trigger manifest baseline in the submitted ledger entry — freshness cannot be proven; provenance NOT stamped; human verification needed`);
|
|
366
|
-
}
|
|
367
|
-
let currentManifest = null;
|
|
368
|
-
try {
|
|
369
|
-
const manifestText = readFileSync(join(spacesRoot, slug, ".space-build", "manifest.json"), "utf8");
|
|
370
|
-
currentManifest = JSON.parse(manifestText);
|
|
371
|
-
} catch (e) {
|
|
372
|
-
terminal("read-back-unavailable", `cannot read current manifest for freshness check: ${e.message}`);
|
|
373
|
-
}
|
|
374
|
-
const currentBuiltAtMs = currentManifest && currentManifest.built_at ? Date.parse(currentManifest.built_at) : NaN;
|
|
375
|
-
const triggerMs = Date.parse(triggerTs);
|
|
376
|
-
if (!(currentBuiltAtMs > triggerMs)) {
|
|
377
|
-
terminal("unverifiable-content", `current manifest built_at ${currentManifest && currentManifest.built_at} did not advance past trigger ${triggerTs} — no new build for this attempt; provenance NOT stamped`);
|
|
378
|
-
}
|
|
379
|
-
if (typeof currentManifest.content_sha256 !== "string" || currentManifest.content_sha256 === manifestBefore.content_sha256) {
|
|
380
|
-
terminal("unverifiable-content", `current manifest content_sha256 is unchanged from the pre-trigger baseline — a replayed manifest, not a new build; provenance NOT stamped; human verification needed`);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// --- 6. Build-ID correlation (informational; the stamp certifies content) ---
|
|
384
|
-
let buildNote = "build-id-unobserved";
|
|
385
|
-
if (buildAgentId && resultText.includes(buildAgentId)) buildNote = `build-id-correlated ${buildAgentId}`;
|
|
386
|
-
|
|
387
|
-
// --- 7. Stamp (only after the machine-checked comparison succeeded) ---------
|
|
388
|
-
let stamp;
|
|
389
|
-
try {
|
|
390
|
-
stamp = api("set-provenance", { project_id: projectId, source_commit: commit, crew_release: crewRelease, task_id: taskId });
|
|
391
|
-
} catch (e) {
|
|
392
|
-
terminal("stamp-failed", `set-provenance threw: ${e.message}`);
|
|
393
|
-
}
|
|
394
|
-
if (!stamp || stamp.ok !== true) terminal("stamp-failed", "set-provenance did not return ok");
|
|
395
|
-
// Exact read-back: the stamp must match what we intended, or it is fiction.
|
|
396
|
-
let prov;
|
|
397
|
-
try {
|
|
398
|
-
prov = api("get-provenance", { project_id: projectId });
|
|
399
|
-
} catch (e) {
|
|
400
|
-
terminal("stamp-failed", `get-provenance threw: ${e.message}`);
|
|
401
|
-
}
|
|
402
|
-
const p = prov && prov.provenance;
|
|
403
|
-
if (!p || p.source_commit !== commit || p.task_id !== taskId || p.crew_release !== crewRelease) {
|
|
404
|
-
terminal("stamp-failed", `provenance read-back mismatch: ${JSON.stringify(p)}`);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// --- 8. Terminal verified event + re-queue -----------------------------------
|
|
408
|
-
const verifiedMsg =
|
|
409
|
-
`publish: verified ${commit} (${inspectionId}) — read-back: all added lines PRESENT, all removed lines ABSENT` +
|
|
410
|
-
(exempted > 0 ? ` (${exempted} colliding line${exempted === 1 ? "" : "s"} exempted as non-discriminating)` : "") +
|
|
411
|
-
`; ${buildNote}; no supersession (HEAD=${commit}).`;
|
|
412
|
-
api("log-event", { task_id: taskId, type: "note", message: verifiedMsg.slice(0, 1000) });
|
|
413
|
-
api("update-task", { id: taskId, state: "in_progress" });
|
|
414
|
-
process.stdout.write(JSON.stringify({ ok: true, verdict: "verified", commit, build_note: buildNote }) + "\n");
|