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/API.md +19 -11
- package/docs/guide.md +2 -2
- package/docs/ooda-report.md +123 -0
- package/docs/publish-verification.md +253 -88
- package/docs/visual-verdict.md +81 -67
- package/lib/AGENTS.md +7 -0
- package/lib/append-ooda-step.js +167 -0
- package/lib/build-readback-request.js +130 -0
- package/lib/compose-evidence-caption.js +141 -0
- package/lib/crew-api.js +281 -2
- package/lib/edit-image.py +216 -0
- package/lib/render-html.js +142 -0
- package/lib/see-act.js +327 -0
- package/lib/serve-artifact.js +203 -0
- package/lib/verify-publish.js +265 -0
- package/lib/write-ooda-verdict.js +130 -0
- package/package.json +1 -1
- package/seed/cron-body-template.md +25 -3
- package/workflows/bugfix.js +220 -214
- package/workflows/chore.js +161 -113
- package/workflows/crew-dispatch.js +1 -1
- package/workflows/docs.js +1 -1
- package/workflows/standard.js +185 -229
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
|
+
});
|