muse-crew 0.7.9 → 0.7.11

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/lib/crew-api.js CHANGED
@@ -16,13 +16,14 @@
16
16
  // create-project, update-project, delete-project, list-projects,
17
17
  // get-project, kill-switch, get-config, update-config,
18
18
  // set-provenance, get-provenance, acknowledge-poll,
19
+ // scan-verification-pending, resolve-publish-unknown,
19
20
  // record-phase (composite), migrate (one-time import)
20
21
  //
21
22
  // Output is JSON on stdout. Errors are JSON on stderr.
22
23
  // Exit codes: 0 ok · 2 usage/validation · 3 not found · 4 conflict/guard.
23
24
 
24
- import { readFileSync, existsSync, statSync } from "node:fs";
25
- import { join, resolve, sep } from "node:path";
25
+ import { readFileSync, existsSync, statSync, readlinkSync, readdirSync, appendFileSync } from "node:fs";
26
+ import { join, resolve, sep, basename } from "node:path";
26
27
  import { randomUUID } from "node:crypto";
27
28
  import { DatabaseSync } from "node:sqlite";
28
29
  import { homedir } from "node:os";
@@ -1063,6 +1064,284 @@ commands["get-events"] = (db, args) => {
1063
1064
  return { events: rows.map(mapEvent) };
1064
1065
  };
1065
1066
 
1067
+ // Parent publish verification: scan + atomic claim + reconcile.
1068
+ //
1069
+ // Finds parked tasks whose latest parent publish note is
1070
+ // "publish: verification-requested" (the publisher parks instead of
1071
+ // stamping, per docs/publish-verification.md) with no terminal parent
1072
+ // verdict and no unexpired verification claim. Each returned task is
1073
+ // ATOMICALLY claimed by logging a "publish: verification-claimed"
1074
+ // note-event with a lease expiry, so two concurrent scanners (or a
1075
+ // retrying tick) cannot start a second verification for the same task.
1076
+ // The claim is a note, not a state change: the task stays parked, so the
1077
+ // dispatcher never dispatches QA mid-verification.
1078
+ //
1079
+ // Also reconciles the verified-but-still-parked gap: a parked task whose
1080
+ // latest parent verdict is "publish: verified" is re-queued to in_progress
1081
+ // (the verdict was recorded but the re-queue was lost to a crash).
1082
+ // Failure verdicts are left parked for human attention (fail-closed).
1083
+ //
1084
+ // Returns { to_verify: [...], reconciled: [...] }.
1085
+ // Each to_verify entry: { task_id, commit, build_agent_id, project_id,
1086
+ // repo_path, deploy_slug, crew_release, claim_expires_at }.
1087
+ //
1088
+ // crew_release is resolved through the crew home's `current` symlink (the
1089
+ // immutable active release the release manager maintains) and cross-checked
1090
+ // against this code's own realpath (the release it is actually running
1091
+ // from). Unresolvable or disputed => throw fail-closed BEFORE the claim
1092
+ // transaction: provenance is never stamped with an unknown release.
1093
+ function resolveActiveRelease(crewHome) {
1094
+ let linkTarget;
1095
+ try {
1096
+ linkTarget = readlinkSync(join(crewHome, "current"));
1097
+ } catch (e) {
1098
+ throw usageError(
1099
+ `scan-verification-pending: cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}). Refusing to verify.`
1100
+ );
1101
+ }
1102
+ const activeRelease = basename(resolve(crewHome, linkTarget));
1103
+ const selfPath = new URL(import.meta.url).pathname;
1104
+ const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
1105
+ if (!selfMatch) {
1106
+ throw usageError(
1107
+ `scan-verification-pending: running code is not inside a releases/<name>/lib path (${selfPath}). Refusing to verify.`
1108
+ );
1109
+ }
1110
+ if (selfMatch[1] !== activeRelease) {
1111
+ throw usageError(
1112
+ `scan-verification-pending: running release ${selfMatch[1]} != active release ${activeRelease} (stale cron body or mid-deploy). Refusing to verify.`
1113
+ );
1114
+ }
1115
+ return activeRelease;
1116
+ }
1117
+ commands["scan-verification-pending"] = (db, args, ctx) => {
1118
+ const CLAIM_LEASE_MS = 60 * 60 * 1000; // 1 hour
1119
+ const nowMs = Date.now();
1120
+ const claimExpiry = new Date(nowMs + CLAIM_LEASE_MS).toISOString();
1121
+ const releaseName = resolveActiveRelease(ctx.crewHome);
1122
+
1123
+ const parked = db.prepare(
1124
+ `SELECT t.id AS task_id, t.project AS project_id,
1125
+ p.repo_path AS repo_path, p.deploy_slug AS deploy_slug
1126
+ FROM tasks t LEFT JOIN projects p ON p.id = t.project
1127
+ WHERE t.state = 'parked'`
1128
+ ).all();
1129
+
1130
+ const toVerify = [];
1131
+ const reconciled = [];
1132
+
1133
+ db.exec("BEGIN");
1134
+ try {
1135
+ for (const row of parked) {
1136
+ const notes = db.prepare(
1137
+ `SELECT message, timestamp FROM events
1138
+ WHERE task_id = ? AND type = 'note' AND message LIKE '%publish:%'
1139
+ ORDER BY timestamp DESC LIMIT 20`
1140
+ ).all(row.task_id);
1141
+ if (notes.length === 0) continue;
1142
+ const latest = notes[0].message;
1143
+
1144
+ // Reconcile first: verdict recorded but the re-queue was lost to a
1145
+ // crash (verified-but-still-parked). Failure verdicts stay parked.
1146
+ // Contained-string matching: the workflow's parkTask prepends
1147
+ // "Parked: " to the reason, so a verification request is stored as
1148
+ // "Parked: publish: verification-requested ..." (observed 2026-09-14).
1149
+ // Prefix anchors would never match; see tests/verify-publish.test.js.
1150
+ if (latest.includes("publish: verified")) {
1151
+ db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?")
1152
+ .run(now(), row.task_id);
1153
+ db.prepare(
1154
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1155
+ VALUES (?, 'note', ?, NULL, ?, ?)`
1156
+ ).run(uuid(), row.task_id,
1157
+ "publish: reconciled verified-but-parked -> in_progress (re-queue lost to a crash)",
1158
+ now());
1159
+ reconciled.push({ task_id: row.task_id });
1160
+ continue;
1161
+ }
1162
+
1163
+ // Terminal parent verdicts (anything the parent decided, except a
1164
+ // fresh request, a procedural error, or an active claim) — never re-verify.
1165
+ const isRequest = latest.includes("publish: verification-requested");
1166
+ const isClaim = latest.includes("publish: verification-claimed");
1167
+ // Procedural errors (worker botched the procedure, not a content
1168
+ // failure) are retryable — treat like a fresh request. Content
1169
+ // failures (verification-failed) stay terminal.
1170
+ const isProceduralError = latest.includes("publish: verification-procedural-error");
1171
+ if (!isRequest && !isClaim && !isProceduralError) continue; // failed / blocked / mismatch: terminal, fail-closed
1172
+
1173
+ if (isClaim) {
1174
+ // Unexpired claim -> another verifier owns it. Expired -> re-claim below.
1175
+ const m = latest.match(/publish: verification-claimed (\S+)/);
1176
+ if (m && Date.parse(m[1]) > nowMs) continue;
1177
+ }
1178
+
1179
+ // Candidate: find the original verification-requested note to extract
1180
+ // the commit and build agent id (the latest note may be an expired claim
1181
+ // or a procedural error).
1182
+ const req = notes.find((n) => n.message.includes("publish: verification-requested"));
1183
+ if (!req) continue;
1184
+ const commitMatch = req.message.match(/verification-requested ([0-9a-f]{40})/);
1185
+ // Fallback: procedural error note carries the commit too.
1186
+ const procMatch = !commitMatch ? latest.match(/verification-procedural-error ([0-9a-f]{40})/) : null;
1187
+ const commit = commitMatch ? commitMatch[1] : (procMatch ? procMatch[1] : null);
1188
+ const buildMatch = req.message.match(/\(build ([0-9a-f-]{36})\)/);
1189
+ if (!commit) continue; // malformed request: cannot verify mechanically, leave parked
1190
+ db.prepare(
1191
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1192
+ VALUES (?, 'note', ?, NULL, ?, ?)`
1193
+ ).run(uuid(), row.task_id, `publish: verification-claimed ${claimExpiry}`, now());
1194
+ toVerify.push({
1195
+ task_id: row.task_id,
1196
+ commit: commit,
1197
+ build_agent_id: buildMatch ? buildMatch[1] : null,
1198
+ project_id: row.project_id,
1199
+ repo_path: row.repo_path,
1200
+ deploy_slug: row.deploy_slug,
1201
+ crew_release: releaseName,
1202
+ claim_expires_at: claimExpiry,
1203
+ });
1204
+ }
1205
+ db.exec("COMMIT");
1206
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
1207
+ return { to_verify: toVerify, reconciled };
1208
+ };
1209
+
1210
+ // Recovery contract for publish attempts parked with an UNKNOWN outcome
1211
+ // (2026-09-14). The structured-output fallback parked the task because no
1212
+ // build could be observed — but the edit may still have gone through (the
1213
+ // in-flight-only fallback could not see a completed build; fixed
1214
+ // in-workflow the same day with the durable audit-dir check). When durable
1215
+ // evidence later shows the build DID complete — a platform audit directory
1216
+ // created inside the publish window (between Integrate completion and the
1217
+ // park event) — this action routes the task to the parent's independent
1218
+ // content verification WITHOUT re-issuing the edit and WITHOUT stamping
1219
+ // provenance. The original unknown ledger entry and park event are
1220
+ // preserved; the resolution is appended to the ledger and the event log.
1221
+ // The parent's read-back (docs/publish-verification.md) remains the real
1222
+ // verification and can still fail terminally. Never: blind retry, manual
1223
+ // stamp, silent state change.
1224
+ commands["resolve-publish-unknown"] = (db, args, ctx) => {
1225
+ const taskId = args.task_id;
1226
+ if (!taskId) throw usageError("task_id is required.");
1227
+ requireTask(db, taskId);
1228
+ const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
1229
+ if (task.state !== "parked") {
1230
+ return { resolved: false, reason: `task is not parked (state=${task.state})` };
1231
+ }
1232
+ const project = db.prepare("SELECT deploy_slug FROM projects WHERE id = ?").get(task.project);
1233
+ const slug = project && project.deploy_slug;
1234
+ if (!slug) return { resolved: false, reason: "project has no deploy_slug" };
1235
+
1236
+ // The latest publish ledger entry for this task must be outcome "unknown".
1237
+ const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
1238
+ let entries = [];
1239
+ try {
1240
+ entries = readFileSync(ledgerPath, "utf8").split("\n")
1241
+ .filter((l) => l.trim().length > 0)
1242
+ .map((l) => JSON.parse(l))
1243
+ .filter((e) => e.task_id === taskId);
1244
+ } catch (e) {
1245
+ return { resolved: false, reason: `cannot read publish ledger: ${e.message}` };
1246
+ }
1247
+ if (entries.length === 0) return { resolved: false, reason: "no publish ledger entries for this task" };
1248
+ const latest = entries[entries.length - 1];
1249
+ if (latest.outcome === "unknown-resolved") {
1250
+ return { resolved: false, reason: "already resolved (unknown-resolved ledger entry present)" };
1251
+ }
1252
+ if (latest.outcome !== "unknown") {
1253
+ return { resolved: false, reason: `latest ledger outcome is '${latest.outcome}', not unknown` };
1254
+ }
1255
+ if (!latest.commit || !/^[0-9a-f]{40}$/.test(latest.commit)) {
1256
+ return { resolved: false, reason: "unknown ledger entry carries no usable commit hash" };
1257
+ }
1258
+
1259
+ // The publish window: Integrate completion -> the unknown-outcome park.
1260
+ // Nothing else touches the artifact during Publish, so an audit dir
1261
+ // created inside this window belongs to this publish attempt.
1262
+ const events = db.prepare(
1263
+ "SELECT type, message, timestamp FROM events WHERE task_id = ? ORDER BY timestamp ASC"
1264
+ ).all(taskId);
1265
+ const integrateDone = [...events].reverse().find((e) =>
1266
+ e.type === "completed" && /integrate completed/i.test(e.message || ""));
1267
+ const parkEvent = [...events].reverse().find((e) =>
1268
+ e.type === "note" && (e.message || "").includes("Publish outcome unknown"));
1269
+ if (!integrateDone || !parkEvent) {
1270
+ return { resolved: false, reason: "cannot determine publish window (missing integrate-completion or unknown-outcome park event)" };
1271
+ }
1272
+ const winStart = Date.parse(integrateDone.timestamp);
1273
+ const winEnd = Date.parse(parkEvent.timestamp);
1274
+ if (!(winStart < winEnd)) {
1275
+ return { resolved: false, reason: "publish window is empty or inverted" };
1276
+ }
1277
+
1278
+ // Audit dirs are named <UTC-timestamp>-<id>; the timestamp is the build's.
1279
+ const auditsDir = join(homedir(), "workspace", "ts-spaces", slug, "audits");
1280
+ let evidenceDirs = [];
1281
+ try {
1282
+ evidenceDirs = readdirSync(auditsDir).filter((name) => {
1283
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z-/.exec(name);
1284
+ if (!m) return false;
1285
+ const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
1286
+ return t > winStart && t <= winEnd;
1287
+ }).sort();
1288
+ } catch (e) {
1289
+ return { resolved: false, reason: `cannot list audit dirs: ${e.message}` };
1290
+ }
1291
+ if (evidenceDirs.length === 0) {
1292
+ return { resolved: false, reason: "no audit build completed inside the publish window — still unknown" };
1293
+ }
1294
+ const evidenceDir = evidenceDirs[0];
1295
+
1296
+ // Corroboration, observation only: the audit harness's own verdict.
1297
+ let auditOk = null;
1298
+ try {
1299
+ auditOk = JSON.parse(readFileSync(join(auditsDir, evidenceDir, "report.json"), "utf8")).ok === true;
1300
+ } catch (e) { auditOk = null; }
1301
+
1302
+ const ts = now();
1303
+ // The verification-requested note must sort strictly after the
1304
+ // unknown-resolved note: scan-verification-pending reads the LATEST
1305
+ // publish: note, and equal timestamps would leave the order undefined.
1306
+ const tsLater = new Date(Date.parse(ts) + 1000).toISOString();
1307
+ db.exec("BEGIN");
1308
+ try {
1309
+ db.prepare(
1310
+ "INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
1311
+ ).run(uuid(), taskId,
1312
+ `publish: unknown-resolved ${latest.commit} (audit ${evidenceDir}, report ok=${auditOk}) — durable build evidence inside the publish window; routing to parent content verification. No re-trigger issued, no provenance stamped, original unknown outcome preserved.`,
1313
+ ts);
1314
+ // Mirror the workflow's verification-requested park note so
1315
+ // scan-verification-pending claims it on the next tick.
1316
+ db.prepare(
1317
+ "INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
1318
+ ).run(uuid(), taskId,
1319
+ `Parked: publish: verification-requested ${latest.commit} (build agent_id unobserved) — recovered from publish-unknown via resolve-publish-unknown; durable evidence ${evidenceDir}. Parent: run docs/publish-verification.md.`,
1320
+ tsLater);
1321
+ db.prepare("UPDATE tasks SET state = 'parked', updated_at = ? WHERE id = ?").run(tsLater, taskId);
1322
+ db.exec("COMMIT");
1323
+ } catch (e) { db.exec("ROLLBACK"); throw e; }
1324
+
1325
+ // Append-only audit trail: the resolution lands in the ledger next to the
1326
+ // original unknown entry (which is never rewritten).
1327
+ try {
1328
+ appendFileSync(ledgerPath, JSON.stringify({
1329
+ ts: new Date(ts).toISOString(),
1330
+ task_id: taskId,
1331
+ workflow: latest.workflow || "unknown",
1332
+ slug,
1333
+ commit: latest.commit,
1334
+ attempt: latest.attempt,
1335
+ agent_id: null,
1336
+ applied_report: null,
1337
+ outcome: "unknown-resolved",
1338
+ detail: `durable build evidence ${evidenceDir} (audit report ok=${auditOk}) inside publish window; routed to parent verification without re-trigger`,
1339
+ }) + "\n");
1340
+ } catch (e) { /* best-effort observability */ }
1341
+
1342
+ return { resolved: true, task_id: taskId, commit: latest.commit, evidence_dir: evidenceDir, audit_report_ok: auditOk };
1343
+ };
1344
+
1066
1345
  // Composite: record a phase's session verdict AND its event in one
1067
1346
  // transaction. This is the operation that used to crash the workflow when the
1068
1347
  // two writes went to different contracts: the session stored, the event
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """edit-image.py — deterministic image editing for experiential evidence.
3
+
4
+ The see-act driver captures full frames; this tool derives closer looks
5
+ from them without a browser: crop a region, zoom into a point, caption a
6
+ frame, or compose an n-up grid. Pillow only — no network, no randomness,
7
+ no timestamps; same inputs always produce byte-identical outputs.
8
+
9
+ Usage:
10
+ python3 edit-image.py crop --in <png> --out <png> --region x,y,w,h
11
+ python3 edit-image.py zoom --in <png> --out <png> --factor <f> [--center x,y]
12
+ python3 edit-image.py label --in <png> --out <png> --text <caption>
13
+ python3 edit-image.py nup --out <png> --cols <n> --in <png> [--in <png> ...]
14
+ [--labels "first|second|third"]
15
+
16
+ crop: extract the pixel region (x, y, width, height). Out of bounds -> error.
17
+ zoom: crop a (w/f, h/f) box centered at --center (default: image center),
18
+ then scale it back up to the original frame size with NEAREST
19
+ (pixel-crisp, so wrapped text vs single-line text stays unambiguous).
20
+ label: append a caption bar under the image with --text.
21
+ nup: arrange --in frames left-to-right, top-to-bottom in --cols columns;
22
+ cells are padded to the largest frame; --labels are drawn as caption
23
+ bars, one per frame, in order (| separated).
24
+
25
+ Stdout is one JSON line: {"ok": true, "out": <path>, "size": [w, h]}.
26
+ Errors print {"ok": false, "error": <msg>} and exit 2.
27
+
28
+ Every derived frame is evidence: the caller must log it (append-ooda-step.js
29
+ --action crop|zoom|label|nup) — a frame produced but not logged is not evidence.
30
+ """
31
+
32
+ import json
33
+ import sys
34
+
35
+ try:
36
+ from PIL import Image, ImageDraw, ImageFont
37
+ except ImportError:
38
+ Image = None
39
+
40
+ CAPTION_PX = 30
41
+ CAPTION_BG = (24, 24, 24)
42
+ CAPTION_FG = (245, 245, 245)
43
+ GRID_BG = (240, 240, 240)
44
+ GRID_GAP = 8
45
+
46
+
47
+ def fail(msg):
48
+ sys.stdout.write(json.dumps({"ok": False, "error": msg}) + "\n")
49
+ sys.exit(2)
50
+
51
+
52
+ def parse_pair(text, name):
53
+ try:
54
+ x, y = text.split(",")
55
+ return int(x), int(y)
56
+ except Exception:
57
+ fail("--%s must be x,y in pixels, got: %s" % (name, text))
58
+
59
+
60
+ def parse_region(text):
61
+ try:
62
+ x, y, w, h = text.split(",")
63
+ x, y, w, h = int(x), int(y), int(w), int(h)
64
+ except Exception:
65
+ fail("--region must be x,y,w,h in pixels, got: " + text)
66
+ if w < 1 or h < 1:
67
+ fail("--region width and height must be positive, got: " + text)
68
+ return x, y, w, h
69
+
70
+
71
+ def load(path):
72
+ try:
73
+ return Image.open(path).convert("RGB")
74
+ except Exception as e:
75
+ fail("cannot read input " + path + ": " + str(e))
76
+
77
+
78
+ def save(img, path):
79
+ try:
80
+ img.save(path, format="PNG")
81
+ except Exception as e:
82
+ fail("cannot write output " + path + ": " + str(e))
83
+ sys.stdout.write(json.dumps({"ok": True, "out": path, "size": [img.width, img.height]}) + "\n")
84
+
85
+
86
+ def cmd_crop(args):
87
+ x, y, w, h = parse_region(args.region)
88
+ img = load(args.in_path)
89
+ if x < 0 or y < 0 or x + w > img.width or y + h > img.height:
90
+ fail("region %s is outside the %dx%d frame" % (args.region, img.width, img.height))
91
+ save(img.crop((x, y, x + w, y + h)), args.out)
92
+
93
+
94
+ def cmd_zoom(args):
95
+ try:
96
+ f = float(args.factor)
97
+ except Exception:
98
+ fail("--factor must be a number, got: " + args.factor)
99
+ if f < 1.01:
100
+ fail("--factor must be > 1, got: " + args.factor)
101
+ img = load(args.in_path)
102
+ cx, cy = parse_pair(args.center, "center") if args.center else (img.width // 2, img.height // 2)
103
+ bw, bh = int(img.width / f), int(img.height / f)
104
+ if bw < 1 or bh < 1:
105
+ fail("--factor %s is too large for a %dx%d frame" % (args.factor, img.width, img.height))
106
+ x, y = cx - bw // 2, cy - bh // 2
107
+ if x < 0 or y < 0 or x + bw > img.width or y + bh > img.height:
108
+ fail("zoom box (%d,%d %dx%d) is outside the %dx%d frame" % (x, y, bw, bh, img.width, img.height))
109
+ box = img.crop((x, y, x + bw, y + bh))
110
+ # NEAREST keeps source pixels crisp — a zoom for inspection must not blur
111
+ # the very pixels (wrapped vs single-line text) under judgment.
112
+ save(box.resize((img.width, img.height), Image.NEAREST), args.out)
113
+
114
+
115
+ def caption_bar(img, text):
116
+ bar = Image.new("RGB", (img.width, CAPTION_PX), CAPTION_BG)
117
+ draw = ImageDraw.Draw(bar)
118
+ font = ImageFont.load_default()
119
+ draw.text((8, 8), text, font=font, fill=CAPTION_FG)
120
+ canvas = Image.new("RGB", (img.width, img.height + CAPTION_PX), GRID_BG)
121
+ canvas.paste(img, (0, 0))
122
+ canvas.paste(bar, (0, img.height))
123
+ return canvas
124
+
125
+
126
+ def cmd_label(args):
127
+ if not args.text:
128
+ fail("--text must not be empty")
129
+ save(caption_bar(load(args.in_path), args.text), args.out)
130
+
131
+
132
+ def cmd_nup(args):
133
+ if not args.ins:
134
+ fail("nup needs at least one --in <png>")
135
+ try:
136
+ cols = int(args.cols)
137
+ except Exception:
138
+ fail("--cols must be a positive integer, got: " + args.cols)
139
+ if cols < 1:
140
+ fail("--cols must be a positive integer, got: " + args.cols)
141
+ labels = args.labels.split("|") if args.labels else []
142
+ if labels and len(labels) != len(args.ins):
143
+ fail("--labels has %d entries for %d inputs" % (len(labels), len(args.ins)))
144
+ frames = [load(p) for p in args.ins]
145
+ if labels:
146
+ frames = [caption_bar(fr, tx) for fr, tx in zip(frames, labels)]
147
+ cw, ch = max(fr.width for fr in frames), max(fr.height for fr in frames)
148
+ rows = (len(frames) + cols - 1) // cols
149
+ canvas = Image.new(
150
+ "RGB",
151
+ (cols * cw + (cols - 1) * GRID_GAP, rows * ch + (rows - 1) * GRID_GAP),
152
+ GRID_BG,
153
+ )
154
+ for i, fr in enumerate(frames):
155
+ r, c = divmod(i, cols)
156
+ ox = c * (cw + GRID_GAP) + (cw - fr.width) // 2
157
+ oy = r * (ch + GRID_GAP) + (ch - fr.height) // 2
158
+ canvas.paste(fr, (ox, oy))
159
+ save(canvas, args.out)
160
+
161
+
162
+ def main():
163
+ if Image is None:
164
+ fail("Pillow (PIL) is required but not installed")
165
+ if len(sys.argv) < 2:
166
+ fail("usage: edit-image.py <crop|zoom|label|nup> [flags]")
167
+ cmd = sys.argv[1]
168
+ rest = sys.argv[2:]
169
+
170
+ # Minimal flag parser (no argparse: keep the tool dependency-free and the
171
+ # surface identical to the other lib scripts).
172
+ flags = {"in": [], "labels": None, "text": None, "region": None,
173
+ "factor": None, "center": None, "cols": None, "out": None}
174
+ i = 0
175
+ while i < len(rest):
176
+ a = rest[i]
177
+ if a == "--in":
178
+ i += 1
179
+ flags["in"].append(rest[i])
180
+ elif a in ("--out", "--region", "--factor", "--center", "--cols", "--text", "--labels"):
181
+ i += 1
182
+ flags[a[2:]] = rest[i]
183
+ else:
184
+ fail("unknown flag: " + a)
185
+ i += 1
186
+
187
+ class Args: # noqa: too few public methods — simple namespace
188
+ pass
189
+ args = Args()
190
+ args.in_path = flags["in"][0] if flags["in"] else None
191
+ args.ins = flags["in"]
192
+ args.out = flags["out"]
193
+ args.region = flags["region"]
194
+ args.factor = flags["factor"]
195
+ args.center = flags["center"]
196
+ args.cols = flags["cols"]
197
+ args.text = flags["text"]
198
+ args.labels = flags["labels"]
199
+
200
+ if cmd in ("crop", "zoom", "label"):
201
+ if not args.in_path:
202
+ fail(cmd + " needs --in <png>")
203
+ if not args.out:
204
+ fail(cmd + " needs --out <png>")
205
+ if cmd == "crop" and not args.region:
206
+ fail("crop needs --region x,y,w,h")
207
+ if cmd == "zoom" and not args.factor:
208
+ fail("zoom needs --factor <f>")
209
+ if cmd == "nup" and not args.out:
210
+ fail("nup needs --out <png>")
211
+
212
+ {"crop": cmd_crop, "zoom": cmd_zoom, "label": cmd_label, "nup": cmd_nup}.get(cmd, lambda a: fail("unknown command: " + cmd))(args)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
@@ -0,0 +1,142 @@
1
+ // render-html.js — render an HTML evidence layout to PNG via headless Chromium.
2
+ //
3
+ // The reef-qa pattern (2026-09-14): the agent composes evidence as HTML —
4
+ // before/after pairs, annotated callouts, n-up grids with real typography —
5
+ // and renders it with the same Chromium the see-act driver uses. Pillow
6
+ // covers pixel ops (crop/zoom); HTML covers composition. This tool is the
7
+ // deterministic renderer: fixed viewport width, system fonts only, hermetic.
8
+ //
9
+ // Usage:
10
+ // node render-html.js --in <page.html> --out <shot.png> [--width <px>]
11
+ //
12
+ // The HTML references images by relative path (resolved against the HTML
13
+ // file's directory) or file:// URL. No http(s) subresources: the render is
14
+ // hermetic, so any http(s) request is aborted and the render fails loudly
15
+ // (exit 2) listing the blocked URLs — a composition that silently drops a
16
+ // remote asset is not evidence.
17
+ //
18
+ // Stdout is one JSON line: {ok:true, out, size:[w,h]}.
19
+ // Exit codes: 0 = ok, 2 = usage/render error, 3 = NOT POSSIBLE (environment:
20
+ // missing playwright-core or Chromium).
21
+ //
22
+ // Determinism: fixed --width (default 1200), deviceScaleFactor 1, full-page
23
+ // screenshot, no wall-clock reads, no randomness, no network. Same HTML +
24
+ // same assets = byte-identical PNG on this host.
25
+ //
26
+ // The rendered composition is evidence: log it with append-ooda-step.js
27
+ // (--action compose) and READ it — a composition you did not read is not
28
+ // evidence.
29
+ "use strict";
30
+
31
+ const { existsSync, readFileSync } = require("node:fs");
32
+ const { resolve, dirname } = require("node:path");
33
+ const { pathToFileURL } = require("node:url");
34
+
35
+ const DEFAULT_WIDTH = 1200;
36
+
37
+ function fail(code, obj) {
38
+ process.stdout.write(JSON.stringify(obj) + "\n");
39
+ process.exit(code);
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const out = { width: DEFAULT_WIDTH };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === "--in") out.in = argv[++i];
47
+ else if (a === "--out") out.out = argv[++i];
48
+ else if (a === "--width") out.width = parseInt(argv[++i], 10);
49
+ else fail(2, { ok: false, error: "unknown flag: " + a });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function loadPlaywright() {
55
+ try {
56
+ return require("playwright-core");
57
+ } catch (e) { /* fall through */ }
58
+ const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
59
+ if (envDir) {
60
+ try {
61
+ return require(envDir + "/playwright-core");
62
+ } catch (e) { /* fall through */ }
63
+ try {
64
+ return require(envDir);
65
+ } catch (e) { /* fall through */ }
66
+ }
67
+ const conventional = "/home/hatch/workspace/crew-tools/node_modules/playwright-core";
68
+ if (existsSync(conventional)) {
69
+ try {
70
+ return require(conventional);
71
+ } catch (e) { /* fall through */ }
72
+ }
73
+ fail(3, {
74
+ ok: false,
75
+ not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
76
+ "Install it (npm install playwright-core) or set PLAYWRIGHT_CORE_DIR.",
77
+ });
78
+ }
79
+
80
+ function findChromium() {
81
+ const envPath = (process.env.CHROME_PATH || "").trim();
82
+ if (envPath && existsSync(envPath)) return envPath;
83
+ const sysPath = "/opt/meta-chromium/chrome";
84
+ if (existsSync(sysPath)) return sysPath;
85
+ fail(3, {
86
+ ok: false,
87
+ not_possible: "NOT POSSIBLE: no Chromium binary found. " +
88
+ "Set CHROME_PATH to a Chromium/Chrome executable.",
89
+ });
90
+ }
91
+
92
+ async function main() {
93
+ const args = parseArgs(process.argv.slice(2));
94
+ if (!args.in) fail(2, { ok: false, error: "missing --in <page.html>" });
95
+ if (!args.out) fail(2, { ok: false, error: "missing --out <shot.png>" });
96
+ if (!Number.isFinite(args.width) || args.width < 200 || args.width > 4000) {
97
+ fail(2, { ok: false, error: "--width must be 200..4000, got: " + args.width });
98
+ }
99
+ const htmlPath = resolve(args.in);
100
+ if (!existsSync(htmlPath)) fail(2, { ok: false, error: "input not found: " + htmlPath });
101
+ void readFileSync(htmlPath, "utf8"); // fail fast on unreadable input
102
+
103
+ const { chromium } = loadPlaywright();
104
+ const exePath = findChromium();
105
+
106
+ let browser = null;
107
+ try {
108
+ browser = await chromium.launch({ executablePath: exePath, args: ["--no-sandbox"] });
109
+ const page = await browser.newPage({
110
+ viewport: { width: args.width, height: 900 },
111
+ deviceScaleFactor: 1,
112
+ });
113
+ // Hermetic render: abort any remote subresource. A composition that
114
+ // silently drops a remote asset is not evidence — fail loudly instead.
115
+ const blocked = [];
116
+ await page.route(/^(https?:)?\/\//, (route) => {
117
+ const url = route.request().url();
118
+ if (url.startsWith("file://")) return route.continue();
119
+ blocked.push(url.slice(0, 200));
120
+ return route.abort();
121
+ });
122
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "networkidle", timeout: 30000 });
123
+ await page.waitForTimeout(500);
124
+ if (blocked.length > 0) {
125
+ fail(2, { ok: false, error: "blocked remote subresources (render must be hermetic): " + blocked.join(", ") });
126
+ }
127
+ await page.screenshot({ path: resolve(args.out), fullPage: true });
128
+ const size = await page.evaluate(() => ({
129
+ w: document.documentElement.scrollWidth,
130
+ h: document.documentElement.scrollHeight,
131
+ }));
132
+ await browser.close();
133
+ process.stdout.write(JSON.stringify({ ok: true, out: resolve(args.out), size: [size.w, size.h] }) + "\n");
134
+ } catch (e) {
135
+ if (browser) await browser.close().catch(() => {});
136
+ fail(2, { ok: false, error: "render failed: " + ((e && e.message) || String(e)).slice(0, 500) });
137
+ }
138
+ }
139
+
140
+ main().catch((e) => {
141
+ fail(2, { ok: false, error: "driver error: " + ((e && e.message) || String(e)).slice(0, 500) });
142
+ });