muse-crew 0.13.3 → 0.14.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,154 @@
1
+ // publish-content.js — shared deterministic content-judgment primitives.
2
+ //
3
+ // Parent publish verification (lib/verify-publish.js) and unknown-recovery
4
+ // classification (lib/classify-publish-absence.js) both judge the same
5
+ // question — "does the on-disk source contain the publish commit's diff?" —
6
+ // against the same diff parser, findings parser, and collision-exemption
7
+ // rules. Two copies of the collision logic already diverged once (2026-09-15,
8
+ // task 00bca4b8); this module is the single source of truth.
9
+ //
10
+ // ESM (imported by verify-publish.js and classify-publish-absence.js).
11
+ // Pure and deterministic: no wall-clock reads, no randomness, no I/O except
12
+ // the git subprocesses in makeOldCounter (read-only).
13
+
14
+ import { execFileSync } from "node:child_process";
15
+
16
+ export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
17
+
18
+ // Parse `git diff base commit` into per-file added/removed lines.
19
+ // Byte-identical split to build-readback-request.js and the old
20
+ // verify-publish.js inline parser (the verifier looks raw lines up
21
+ // verbatim, so all consumers must agree on the split).
22
+ export function parseDiff(diffText) {
23
+ const expected = new Map(); // path -> { added: [], removed: [], isBinary: bool }
24
+ let curFile = null;
25
+ for (const line of diffText.split("\n")) {
26
+ if (line.startsWith("diff --git")) {
27
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
28
+ curFile = m ? m[2] : "unknown";
29
+ expected.set(curFile, { added: [], removed: [], isBinary: false });
30
+ } else if (curFile && line.startsWith("Binary files ")) {
31
+ expected.get(curFile).isBinary = true;
32
+ } else if (curFile && line.startsWith("+") && !line.startsWith("+++")) {
33
+ expected.get(curFile).added.push(line.slice(1));
34
+ } else if (curFile && line.startsWith("-") && !line.startsWith("---")) {
35
+ expected.get(curFile).removed.push(line.slice(1));
36
+ }
37
+ }
38
+ return expected;
39
+ }
40
+
41
+ // Parse the machine-readable findings block:
42
+ // FILE: <path>
43
+ // ADDED: <line> :: PRESENT|ABSENT
44
+ // REMOVED: <line> :: PRESENT|ABSENT
45
+ // END_FILE
46
+ export function parseFindings(text) {
47
+ const findings = new Map(); // path -> { added: Map(line->verdict), removed: Map(line->verdict) }
48
+ let cur = null;
49
+ let malformed = null;
50
+ for (const rawLine of text.split("\n")) {
51
+ const line = rawLine.trimEnd();
52
+ if (line.startsWith("FILE: ")) {
53
+ cur = { added: new Map(), removed: new Map() };
54
+ findings.set(line.slice(6).trim(), cur);
55
+ } else if (line === "END_FILE") {
56
+ cur = null;
57
+ } else if (cur && (line.startsWith("ADDED: ") || line.startsWith("REMOVED: "))) {
58
+ const kind = line.startsWith("ADDED: ") ? "added" : "removed";
59
+ const rest = line.slice(kind === "added" ? 7 : 9);
60
+ const sep = rest.lastIndexOf(" :: ");
61
+ if (sep < 0) { malformed = `malformed finding line: ${line.slice(0, 80)}`; break; }
62
+ const content = rest.slice(0, sep);
63
+ const verdict = rest.slice(sep + 4).trim();
64
+ if (verdict !== "PRESENT" && verdict !== "ABSENT") {
65
+ malformed = `bad verdict: ${verdict}`;
66
+ break;
67
+ }
68
+ cur[kind].set(content, verdict);
69
+ }
70
+ }
71
+ return { findings, malformed };
72
+ }
73
+
74
+ // Whole-line occurrence counts of a file's content at <base>.
75
+ // [] when the base is the empty tree or the file did not exist there.
76
+ function oldTreeLines(repoPath, base, path) {
77
+ if (base === EMPTY_TREE) return [];
78
+ let text;
79
+ try {
80
+ text = execFileSync("git", ["-C", repoPath, "show", `${base}:${path}`], {
81
+ encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
82
+ });
83
+ } catch {
84
+ return [];
85
+ }
86
+ if (text === "") return [];
87
+ const lines = text.split("\n");
88
+ if (lines[lines.length - 1] === "") lines.pop(); // drop the trailing-newline artifact
89
+ return lines;
90
+ }
91
+
92
+ // Returns (path, line) => occurrences of line in path's old tree.
93
+ // Cached per path. Read-only git subprocesses.
94
+ export function makeOldCounter(repoPath, base) {
95
+ const cache = new Map(); // path -> Map(line -> count)
96
+ return (path, line) => {
97
+ let counts = cache.get(path);
98
+ if (counts === undefined) {
99
+ counts = new Map();
100
+ for (const l of oldTreeLines(repoPath, base, path)) {
101
+ counts.set(l, (counts.get(l) || 0) + 1);
102
+ }
103
+ cache.set(path, counts);
104
+ }
105
+ return counts.get(line) || 0;
106
+ };
107
+ }
108
+
109
+ // Split each file's diff lines into discriminating vs non-discriminating.
110
+ //
111
+ // A line has zero discriminating power when its presence/absence cannot
112
+ // tell "hunk landed" from "hunk dropped":
113
+ // - added line already in the old tree (colliding pre-existing line):
114
+ // PRESENT whether or not the hunk landed.
115
+ // - removed line occurring in the old tree strictly more times than the
116
+ // diff removes it: PRESENT whether or not the removal happened.
117
+ //
118
+ // Returns Map(path -> {
119
+ // addedDisc: [...], removedDisc: [...], // discriminating lines
120
+ // unjudgeable: null | "binary-or-mode-or-rename" | "fully-colliding-added"
121
+ // }).
122
+ // unjudgeable marks files the line-based judge cannot certify at all:
123
+ // a diff with no content lines (binary, mode-only, rename), or added
124
+ // lines that ALL collide (their PRESENT findings prove nothing).
125
+ export function discriminatingLines(diffMap, oldCount) {
126
+ const out = new Map();
127
+ for (const [path, exp] of diffMap) {
128
+ const entry = { addedDisc: [], removedDisc: [], unjudgeable: null };
129
+ if (exp.added.length === 0 && exp.removed.length === 0) {
130
+ entry.unjudgeable = "binary-or-mode-or-rename";
131
+ out.set(path, entry);
132
+ continue;
133
+ }
134
+ for (const line of exp.added) {
135
+ if (oldCount(path, line) === 0) entry.addedDisc.push(line);
136
+ }
137
+ // The diff may remove the same line more than once; the old tree must
138
+ // account for every removal before a line counts as colliding.
139
+ const removedBudget = new Map();
140
+ for (const line of exp.removed) {
141
+ removedBudget.set(line, (removedBudget.get(line) || 0) + 1);
142
+ }
143
+ for (const line of exp.removed) {
144
+ if (oldCount(path, line) <= removedBudget.get(line)) {
145
+ entry.removedDisc.push(line);
146
+ }
147
+ }
148
+ if (exp.added.length > 0 && entry.addedDisc.length === 0) {
149
+ entry.unjudgeable = "fully-colliding-added";
150
+ }
151
+ out.set(path, entry);
152
+ }
153
+ return out;
154
+ }
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5).
3
+ //
4
+ // The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
5
+ // lock handling, journaling, diff generation, ledger writes, mirroring, and
6
+ // cleanup by following template instructions. A missed step (or a tick dying
7
+ // mid-protocol) left the lock held or the state inconsistent.
8
+ //
9
+ // This command owns all deterministic transitions with try/finally lock
10
+ // release. Only the unavoidable `artifact_edit` tool call remains
11
+ // agent-mediated (it requires the artifact namespace, which this process
12
+ // cannot load).
13
+ //
14
+ // Two phases:
15
+ // Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
16
+ // journal intent, generate diff. Outputs JSON with the diff path/sha
17
+ // for the agent to feed to artifact_edit. The lock is HELD on success
18
+ // (the agent must call phase 2 to release it).
19
+ // Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
20
+ // write the outcome notes, append the ledger entry, mirror verification,
21
+ // and RELEASE the lock in a finally block.
22
+ //
23
+ // Crash safety: if the agent dies between phases, the lock expires after
24
+ // 600s and the note journal (`publish: retry-intended` without
25
+ // `publish: retry-issued`) lets the next scan mirror verification-requested
26
+ // without re-triggering — exactly one retry, enforced from notes.
27
+ //
28
+ // Usage:
29
+ // node retry-publish.js --crew-home <path> --task-id <uuid> --phase prepare
30
+ // node retry-publish.js --crew-home <path> --task-id <uuid> --phase complete --edit-outcome <accepted|refused> [--refusal-text <text>]
31
+ //
32
+ // Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
33
+
34
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
35
+ import { join } from "path";
36
+ import { execFileSync } from "child_process";
37
+ import { randomUUID as uuid } from "crypto";
38
+
39
+ const args = process.argv.slice(2);
40
+ function arg(name, required = true, def = undefined) {
41
+ const i = args.indexOf(name);
42
+ if (i === -1 || i + 1 >= args.length) {
43
+ if (required) { console.error(`missing required arg ${name}`); process.exit(2); }
44
+ return def;
45
+ }
46
+ return args[i + 1];
47
+ }
48
+ function fail(code, msg, exitCode = 1) {
49
+ process.stdout.write(JSON.stringify({ ok: false, code, message: msg }) + "\n");
50
+ process.exit(exitCode);
51
+ }
52
+
53
+ const crewHome = arg("--crew-home");
54
+ const taskId = arg("--task-id");
55
+ const phase = arg("--phase");
56
+
57
+ if (!["prepare", "complete"].includes(phase)) {
58
+ console.error("phase must be 'prepare' or 'complete'");
59
+ process.exit(2);
60
+ }
61
+
62
+ // --- Shared helpers ---------------------------------------------------------
63
+
64
+ function nowISO() { return new Date().toISOString(); }
65
+
66
+ function writePublishNote(db, taskId, message) {
67
+ db.prepare(
68
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
69
+ VALUES (?, 'note', ?, NULL, ?, ?)`
70
+ ).run(uuid(), taskId, message, nowISO());
71
+ }
72
+
73
+ // Load the crew DB. We use better-sqlite3 via the crew-api's own dependency
74
+ // resolution — fall back to a clear error if unavailable.
75
+ let Database;
76
+ try {
77
+ Database = (await import("better-sqlite3")).default;
78
+ } catch {
79
+ fail("db-unavailable", "better-sqlite3 is not available to retry-publish.js");
80
+ }
81
+ const dbPath = join(crewHome, "crew.db");
82
+ if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
83
+ const db = new Database(dbPath);
84
+
85
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
86
+
87
+ function runMergeLock(action) {
88
+ const lockScript = join(crewHome, "lib", "merge-lock.sh");
89
+ try {
90
+ execFileSync(lockScript, [action, taskId], {
91
+ env: { ...process.env, CREW_HOME: crewHome },
92
+ encoding: "utf8",
93
+ timeout: 30000,
94
+ });
95
+ return { ok: true };
96
+ } catch (e) {
97
+ return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
98
+ }
99
+ }
100
+
101
+ // --- Phase 1: prepare --------------------------------------------------------
102
+ // Acquires the lock, re-checks HEAD, re-classifies, journals intent,
103
+ // generates the diff. On success the lock is HELD and the output carries
104
+ // the diff path/sha for the agent's artifact_edit child.
105
+
106
+ if (phase === "prepare") {
107
+ // Derive the unknown attempt (same derivation as scan-publish-unknown).
108
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
109
+ if (!task) fail("unknown-task", `task ${taskId} not found`);
110
+ if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
111
+
112
+ const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
113
+ const slug = project && project.deploy_slug;
114
+ if (!slug) fail("no-slug", "project has no deploy_slug");
115
+ const repoPath = (project && project.repo_path) || null;
116
+
117
+ const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
118
+ let entries = [];
119
+ try {
120
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
121
+ .filter((l) => l.trim().length > 0)
122
+ .map((l) => JSON.parse(l))
123
+ .filter((e) => e.task_id === taskId);
124
+ } catch (e) {
125
+ fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
126
+ }
127
+ const unknownEntry = entries[entries.length - 1];
128
+ if (!unknownEntry || unknownEntry.outcome !== "unknown") {
129
+ fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
130
+ }
131
+ const commit = unknownEntry.commit;
132
+ const attempt = unknownEntry.attempt || null;
133
+ if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
134
+ fail("bad-commit", "unknown ledger entry carries no usable commit hash");
135
+ }
136
+
137
+ // 1. Re-check HEAD: must still be the retry target.
138
+ let head;
139
+ try {
140
+ head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
141
+ } catch (e) {
142
+ fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
143
+ }
144
+ if (head !== commit) {
145
+ writePublishNote(db, taskId,
146
+ `publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
147
+ process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
148
+ process.exit(0);
149
+ }
150
+
151
+ // 2. Acquire the merge lock.
152
+ const lock = runMergeLock("acquire");
153
+ if (!lock.ok) {
154
+ process.stdout.write(JSON.stringify({
155
+ ok: true, phase: "prepare", outcome: "lock-held",
156
+ message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
157
+ }) + "\n");
158
+ process.exit(0);
159
+ }
160
+
161
+ // From here to the end of phase 1, the lock is held. Any failure must
162
+ // release it — try/finally.
163
+ let lockHeld = true;
164
+ const releaseLock = () => {
165
+ if (lockHeld) {
166
+ runMergeLock("release");
167
+ lockHeld = false;
168
+ }
169
+ };
170
+
171
+ try {
172
+ // 3. Inside the lock, re-classify.
173
+ let base = EMPTY_TREE;
174
+ try {
175
+ const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
176
+ if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
177
+ } catch (e) {
178
+ releaseLock();
179
+ fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
180
+ }
181
+
182
+ const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
183
+ // Find the trigger entry for the manifest baseline.
184
+ const trigger = [...entries].reverse().find((e) =>
185
+ e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
186
+ const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
187
+
188
+ // Find the park note timestamp for quiesce calculation.
189
+ const parkNote = db.prepare(
190
+ `SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
191
+ AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
192
+ ORDER BY timestamp DESC LIMIT 1`
193
+ ).get(taskId);
194
+ if (!parkNote) {
195
+ releaseLock();
196
+ fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
197
+ }
198
+
199
+ const classifierArgs = [
200
+ classifierPath,
201
+ "--slug", slug,
202
+ "--commit", commit,
203
+ "--base", base,
204
+ "--trigger-ts", trigger ? trigger.ts : parkNote.timestamp,
205
+ "--park-ts", parkNote.timestamp,
206
+ "--repo-path", repoPath,
207
+ ];
208
+ if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
209
+
210
+ let classification;
211
+ try {
212
+ const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
213
+ classification = JSON.parse(out);
214
+ } catch (e) {
215
+ releaseLock();
216
+ fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
217
+ }
218
+
219
+ if (!classification.ok || classification.decision !== "provably-dropped") {
220
+ // Not dropped — release the lock and route via the existing
221
+ // record-retry-recheck path (the code owns the routing).
222
+ releaseLock();
223
+ process.stdout.write(JSON.stringify({
224
+ ok: true, phase: "prepare", outcome: "not-dropped",
225
+ decision: classification.decision,
226
+ message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
227
+ classification,
228
+ }) + "\n");
229
+ process.exit(0);
230
+ }
231
+
232
+ // 3b. Capture the pre-retry manifest baseline (inside the lock).
233
+ let retryBaseline = null;
234
+ try {
235
+ const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
236
+ if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
237
+ retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
238
+ }
239
+ } catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
240
+
241
+ // 4. Journal the intent BEFORE the trigger.
242
+ writePublishNote(db, taskId,
243
+ `publish: retry-intended ${commit} ${nowISO()} — re-issuing the dropped edit once; if this tick dies before the trigger returns, the next scan mirrors verification-requested rather than re-triggering.`);
244
+
245
+ // 5. Regenerate the diff deterministically.
246
+ const diffPath = `/tmp/retry-diff-${taskId}.txt`;
247
+ let diffMeta;
248
+ try {
249
+ const out = execFileSync("node", [
250
+ join(crewHome, "lib", "compute-publish-diff.js"),
251
+ "--repo-path", repoPath,
252
+ "--base", base,
253
+ "--out", diffPath,
254
+ ], { encoding: "utf8" });
255
+ diffMeta = JSON.parse(out);
256
+ } catch (e) {
257
+ releaseLock();
258
+ fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
259
+ }
260
+ if (!diffMeta.ok) {
261
+ releaseLock();
262
+ fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
263
+ }
264
+
265
+ // Success: lock remains HELD. The agent spawns the artifact_edit child
266
+ // with the diff, then calls phase 2 (complete) to finish.
267
+ // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
268
+ // but phase 2 needs the exact commit/attempt/baseline/ledger path).
269
+ const statePath = `/tmp/retry-publish-${taskId}.json`;
270
+ writeFileSync(statePath, JSON.stringify({
271
+ task_id: taskId, commit, attempt, slug, repoPath,
272
+ project_id: project.id, ledger_path: ledgerPath,
273
+ retry_baseline: retryBaseline,
274
+ diff_path: diffPath, diff_sha256: diffMeta.sha256,
275
+ prepared_at: nowISO(),
276
+ }));
277
+
278
+ process.stdout.write(JSON.stringify({
279
+ ok: true, phase: "prepare", outcome: "ready",
280
+ task_id: taskId, commit, attempt,
281
+ diff_path: diffPath, diff_sha256: diffMeta.sha256,
282
+ slug,
283
+ message: "lock held; spawn the artifact_edit child with the diff, then call phase 2 (complete)",
284
+ }) + "\n");
285
+ process.exit(0);
286
+ } catch (e) {
287
+ releaseLock();
288
+ throw e;
289
+ }
290
+ }
291
+
292
+ // --- Phase 2: complete -------------------------------------------------------
293
+ // Given the artifact_edit outcome, write the outcome notes, append the
294
+ // ledger entry, mirror verification, and RELEASE the lock (finally).
295
+
296
+ if (phase === "complete") {
297
+ const editOutcome = arg("--edit-outcome");
298
+ if (!["accepted", "refused"].includes(editOutcome)) {
299
+ console.error("--edit-outcome must be 'accepted' or 'refused'");
300
+ process.exit(2);
301
+ }
302
+ const refusalText = arg("--refusal-text", false, "");
303
+
304
+ const statePath = `/tmp/retry-publish-${taskId}.json`;
305
+ if (!existsSync(statePath)) {
306
+ fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
307
+ }
308
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
309
+ const { commit, attempt, slug, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
310
+
311
+ let lockHeld = true;
312
+ const releaseLock = () => {
313
+ if (lockHeld) {
314
+ runMergeLock("release");
315
+ lockHeld = false;
316
+ }
317
+ };
318
+
319
+ try {
320
+ if (editOutcome === "refused") {
321
+ // Conclusive negative: rejected, not unknown. Terminal.
322
+ writePublishNote(db, taskId,
323
+ `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
324
+ process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
325
+ return;
326
+ }
327
+
328
+ // The child ended after the edit call with no explicit refusal —
329
+ // fire-and-forget, same shape as the first attempt.
330
+ writePublishNote(db, taskId,
331
+ `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
332
+
333
+ // Mirror verification with a settle window.
334
+ const notBefore = new Date(Date.now() + 20 * 60 * 1000).toISOString();
335
+ const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
336
+ writePublishNote(db, taskId,
337
+ `publish: verification-requested ${commit} attempt=${retryAttempt} not-before=${notBefore} — unknown-recovery retried the dropped edit once; content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
338
+
339
+ // Append the -retry1 ledger entry with the pre-retry baseline (required).
340
+ const ledgerEntry = {
341
+ ts: nowISO(),
342
+ task_id: taskId,
343
+ workflow: "unknown-recovery",
344
+ slug,
345
+ commit,
346
+ attempt: retryAttempt,
347
+ agent_id: null,
348
+ applied_report: null,
349
+ outcome: "submitted",
350
+ manifest_before: retryBaseline,
351
+ detail: "retry trigger re-issued after provably-dropped classification",
352
+ };
353
+ try {
354
+ appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
355
+ } catch (e) {
356
+ // Best-effort: the note journal is the source of truth, not the ledger.
357
+ console.error(`ledger append failed (non-fatal): ${e.message}`);
358
+ }
359
+
360
+ process.stdout.write(JSON.stringify({
361
+ ok: true, phase: "complete", outcome: "issued",
362
+ commit, attempt: retryAttempt, not_before: notBefore,
363
+ }) + "\n");
364
+ } finally {
365
+ releaseLock();
366
+ // Clean up the phase-1 state file (best-effort).
367
+ try { (await import("fs")).unlinkSync(statePath); } catch {}
368
+ }
369
+ process.exit(0);
370
+ }