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.
@@ -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");