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/docs/visual-verdict.md
CHANGED
|
@@ -1,28 +1,85 @@
|
|
|
1
|
-
# The Visual Verdict —
|
|
1
|
+
# The Visual Verdict — Hazel owns it
|
|
2
|
+
|
|
3
|
+
> **UPDATE (2026-09-15):** the parent post-change verdict protocol is
|
|
4
|
+
> **retired**. There is no `visual_verdict:` note contract, no
|
|
5
|
+
> `visualVerdictStatus()` gate, no `visual: pending` marker, and no parent
|
|
6
|
+
> capture/composition step. Hazel (the QA work agent) drives the artifact
|
|
7
|
+
> herself with `lib/see-act.js`, logs every step to the OODA report, and
|
|
8
|
+
> records her verdict machine-readably — the workflow trusts her verdict
|
|
9
|
+
> and never parks for a parent one. The baseline-capture protocol below is
|
|
10
|
+
> unchanged: baselines are still captured before the fix exists, which the
|
|
11
|
+
> workflow cannot do synchronously.
|
|
12
|
+
|
|
13
|
+
> **UPDATE (2026-09-14):** the agent-owned see-act loop superseded the
|
|
14
|
+
> blocked platform path. `artifact_inspect` was removed by the platform and
|
|
15
|
+
> `artifact.inspect` is malfunction diagnosis, not a substitute — but the
|
|
16
|
+
> workflow no longer needs either: the QA/repro agent drives the artifact
|
|
17
|
+
> itself with `lib/see-act.js` (one browser action per invocation, reads
|
|
18
|
+
> its own screenshots) against `lib/serve-artifact.js` (local server for the
|
|
19
|
+
> built artifact). Experiential tasks get agent-run visual inspection in
|
|
20
|
+
> the QA phase (standard, bugfix) and experiential repro (bugfix).
|
|
2
21
|
|
|
3
22
|
QA owns the visual verdict for experiential artifact tasks: does the change
|
|
4
|
-
*look* right, judged by Hazel's artist-eye rubric, from rendered evidence
|
|
5
|
-
|
|
6
|
-
mechanical halves; the parent handles everything that needs eyes.
|
|
23
|
+
*look* right, judged by Hazel's artist-eye rubric, from rendered evidence
|
|
24
|
+
she captured herself.
|
|
7
25
|
|
|
8
|
-
## Why
|
|
26
|
+
## Why Hazel does it herself
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
The old model was async: rendered post-change evidence was delivered to
|
|
29
|
+
the root agent — the parent — which then recorded a `visual_verdict:` note
|
|
30
|
+
the workflow gated on. That split the verdict across two systems and left
|
|
31
|
+
tasks parked whenever the parent path stalled.
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
evidence, generating composites, launching Hazel only after rendered
|
|
20
|
-
evidence arrives, recording `visual_verdict`, and closing, reworking, or
|
|
21
|
-
parking the task.
|
|
33
|
+
The new model is depth-1: the QA work agent IS the inspector. She starts
|
|
34
|
+
the local artifact server, drives it one browser step at a time, reads
|
|
35
|
+
every frame, edits and composes evidence when a raw screenshot is not
|
|
36
|
+
enough, and records her verdict. The contract is:
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
- **Hazel-owned:** the experiential loop, the OODA report
|
|
39
|
+
(`ooda-log.jsonl` + archived frames + derivatives), `verdict.json` and
|
|
40
|
+
the append-only `verdicts.jsonl` ledger, the visual verdict itself.
|
|
41
|
+
- **Workflow-owned:** the experiential flag, the Capture phase, the Map
|
|
42
|
+
baseline gate, the mechanical QA checks, trusting Hazel's verdict.
|
|
43
|
+
- **Parent-owned:** baseline capture only (see below) — baselines are
|
|
44
|
+
captured before the fix exists, which the workflow cannot do
|
|
45
|
+
synchronously.
|
|
46
|
+
|
|
47
|
+
No experiential artifact task completes without Hazel's recorded verdict.
|
|
48
|
+
A missing OODA log is a broken report, not a pass. Unknown is neither
|
|
49
|
+
PASS nor FAIL — un-runnable checks are named as explicit evidence gaps.
|
|
50
|
+
|
|
51
|
+
## The OODA report
|
|
52
|
+
|
|
53
|
+
Task evidence lives under `$CREW_HOME/task-evidence/<task-id>/<phase>/`
|
|
54
|
+
(`repro/` for bugfix Reproduce, `postchange/` for QA):
|
|
55
|
+
|
|
56
|
+
- `ooda-log.jsonl` — ordered steps with observations. Every step carries
|
|
57
|
+
an `--attempt` identity; steps are strictly monotonic within an attempt;
|
|
58
|
+
a rerun is a new attempt at step 1 — attempts accumulate, never
|
|
59
|
+
overwrite. Actions: `aria|shot|click|scroll|type` (browser) and
|
|
60
|
+
`crop|zoom|label|nup|compose` (evidence derivatives).
|
|
61
|
+
- Archived frames — `001-shot-desktop.png`, `002-click-mobile.png`, … via
|
|
62
|
+
`SEE_ACT_ARCHIVE_DIR`. A frame Hazel did not read is not evidence.
|
|
63
|
+
- `verdict.json` — the latest machine-readable verdict
|
|
64
|
+
(`{verdict, summary, expected, actual, missing_evidence}`).
|
|
65
|
+
- `verdicts.jsonl` — the append-only ledger: every attempt's verdict is
|
|
66
|
+
preserved with a mechanical sequence number, never overwritten.
|
|
67
|
+
|
|
68
|
+
## Evidence editing and composition
|
|
69
|
+
|
|
70
|
+
When a raw screenshot is not enough evidence, Hazel has two deterministic
|
|
71
|
+
layers:
|
|
72
|
+
|
|
73
|
+
- **Pixel operations** (`lib/edit-image.py`, Pillow): `crop` (pixel-exact,
|
|
74
|
+
rejects out-of-bounds), `zoom` (nearest-neighbor, back to frame size),
|
|
75
|
+
`label` (caption bar), `nup` (side-by-side grid with labels).
|
|
76
|
+
- **Composition** (`lib/render-html.js`, headless Chromium): renders a
|
|
77
|
+
local HTML layout to PNG for rich before/after presentations with real
|
|
78
|
+
typography. Hermetic — remote assets are blocked and fail loudly.
|
|
79
|
+
|
|
80
|
+
Derivatives supplement, never replace: the source frame stays archived
|
|
81
|
+
and is named in the step's args. Every derivative is logged and read —
|
|
82
|
+
an unread image is not evidence.
|
|
26
83
|
|
|
27
84
|
## Evidence layout
|
|
28
85
|
|
|
@@ -31,16 +88,10 @@ Task evidence lives under:
|
|
|
31
88
|
```
|
|
32
89
|
$CREW_HOME/task-evidence/<task-id>/
|
|
33
90
|
baseline/ baseline captures (pre-change)
|
|
34
|
-
postchange/ post-change captures
|
|
35
|
-
|
|
91
|
+
postchange/ post-change captures + OODA report
|
|
92
|
+
repro/ bugfix reproduction captures + OODA report
|
|
36
93
|
```
|
|
37
94
|
|
|
38
|
-
Capture filenames follow the deterministic frame used by the workflow's
|
|
39
|
-
`buildVisualCapturePlan` output: `<kind>-<n>-<viewport>-<state>.png`
|
|
40
|
-
(e.g. `baseline-01-desktop1440x900-top.png`). The composites are named
|
|
41
|
-
`<stem>-sidebyside.png` and `<stem>-overlay.png` by
|
|
42
|
-
`lib/compose-evidence.py`.
|
|
43
|
-
|
|
44
95
|
## Baseline capture protocol
|
|
45
96
|
|
|
46
97
|
Triggered by the Capture phase when the task is experiential and no
|
|
@@ -69,33 +120,6 @@ baseline evidence is recorded yet. The workflow logs
|
|
|
69
120
|
The workflow requests at most two attempts; after two it records
|
|
70
121
|
`baseline: none (capture unavailable after 2 attempts)` itself.
|
|
71
122
|
|
|
72
|
-
## Post-change capture, composition, Hazel, verdict
|
|
73
|
-
|
|
74
|
-
After the QA step completes its mechanical checks, the QA session notes
|
|
75
|
-
carry `visual: pending` and the deterministic `capture_plan:` — the parent
|
|
76
|
-
then:
|
|
77
|
-
|
|
78
|
-
1. Trigger the post-change inspection with the `capture_plan:` frame from
|
|
79
|
-
the QA session notes. Save captures under `postchange/`.
|
|
80
|
-
2. Run `python3 lib/compose-evidence.py <baseline-dir> <postchange-dir>
|
|
81
|
-
<composites-dir>` and keep its `PAIR...` / `SKIP...` manifest output as
|
|
82
|
-
evidence refs.
|
|
83
|
-
3. Launch Hazel with `identities/hazel.md` (artist-eye rubric + verdict
|
|
84
|
-
discipline), the baseline refs, the post-change refs, and the composite
|
|
85
|
-
manifest. She receives rendered evidence only — never a prose
|
|
86
|
-
description of what the change "looks like".
|
|
87
|
-
4. Record her verdict as a task note event:
|
|
88
|
-
`visual_verdict: PASS <composite refs>` or
|
|
89
|
-
`visual_verdict: FAIL <reason> <refs>`.
|
|
90
|
-
5. Routing:
|
|
91
|
-
- **PASS** — the task is done; close it.
|
|
92
|
-
- **FAIL** — send the task back to Build as budgeted rework, with the
|
|
93
|
-
FAIL reason and refs as the rejection notes.
|
|
94
|
-
- **FAIL with reason beginning `rendering impossible:`** — the target
|
|
95
|
-
cannot be rendered at all (never a rework loop). Park the task for
|
|
96
|
-
human attention. A verdict can never be issued without rendered
|
|
97
|
-
evidence; impossibility fails, it never passes.
|
|
98
|
-
|
|
99
123
|
## Exact note-event prefixes
|
|
100
124
|
|
|
101
125
|
Case-sensitive, exact-prefix matches — the workflow matches on prefixes,
|
|
@@ -105,16 +129,6 @@ never on English meaning:
|
|
|
105
129
|
- `baseline: captured <refs>` — parent, after saving baseline captures.
|
|
106
130
|
- `baseline: none (<reason>)` — baseline not capturable; final QA judges
|
|
107
131
|
on the rubric alone and states that explicitly.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Build within the shared budget.
|
|
112
|
-
|
|
113
|
-
## Recovery: rebuilding the capture plan
|
|
114
|
-
|
|
115
|
-
If the QA session notes are truncated (summary cap) and `capture_plan:`
|
|
116
|
-
is lost, the frame is deterministic — rebuild it from the workflow
|
|
117
|
-
source: `buildVisualCapturePlan(taskTitle, taskDescription,
|
|
118
|
-
"postchange", captureTargets)` in `workflows/standard.js` (identical in
|
|
119
|
-
`bugfix.js` and `chore.js`). Capture targets come from the Map step's
|
|
120
|
-
`capture_targets:` marker line; fall back to the task description.
|
|
132
|
+
|
|
133
|
+
The `visual_verdict:` prefixes are retired (2026-09-15) — Hazel's verdict
|
|
134
|
+
lives in `verdict.json` / `verdicts.jsonl`, not in note events.
|
package/lib/AGENTS.md
CHANGED
|
@@ -14,3 +14,10 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
14
14
|
- `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
|
|
15
15
|
- `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
|
|
16
16
|
- `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
|
|
17
|
+
- `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the latest `visual_verdict:` note, the `publish: verified` commit (or the global provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note when the task's own run recorded `baseline: none`. Exits non-zero on missing input.
|
|
18
|
+
- `edit-image.py` — deterministic pixel-level evidence editing (2026-09-15, Pillow): `crop --region x,y,w,h` (pixel-exact, rejects out-of-bounds), `zoom --factor <f> [--center x,y]` (nearest-neighbor, returns to source frame size), `label --text <caption>` (caption bar), `nup --cols <n> --in <a.png> --in <b.png> [--labels "a|b"]` (side-by-side grid). Byte-deterministic PNG output; no network, no time, no randomness. Derivatives supplement raw frames — the source frame stays archived.
|
|
19
|
+
- `render-html.js` — deterministic HTML evidence composition (2026-09-15, headless Chromium): renders a local HTML layout to PNG (fixed viewport width, device scale 1, full-page screenshot). Hermetic: remote HTTP(S) assets are blocked and fail the render loudly; relative image paths resolve against the HTML file; local/system fonts only. Exit 3 reports NOT POSSIBLE when Chromium is unavailable. The composition layer above edit-image.py's pixel layer.
|
|
20
|
+
- `see-act.js` — single-step browser driver for experiential QA (2026-09-14): one browser action per invocation (`aria`, `shot`, `click`, `scroll`, `type`), one JSON line on stdout, exit 0/2/3 (3 = NOT POSSIBLE: missing playwright-core or Chromium). Spawns its own loopback forward proxy on an ephemeral port (Chromium blocks direct loopback). The QA work agent closes the OODA loop: run a step, read the screenshot/aria, decide the next. Determinism: no wall-clock reads, no randomness. `SEE_ACT_ARCHIVE_DIR=<phase-dir>` (2026-09-14): every screenshot is archived automatically as `001-shot-desktop.png`, `002-click-mobile.png`, ... (counter in `<dir>/.seq`); `--out` becomes optional; JSON carries `screenshot` + `archived`; explicit `--out` + archive copies into the archive; unusable dir is NOT POSSIBLE exit 3.
|
|
21
|
+
- `append-ooda-step.js` — deterministic writer for the OODA report log (2026-09-14, attempt identity 2026-09-15): `node append-ooda-step.js --log <path> --attempt <id> --step <n> --action <a> --exit <code> [--args <json>] [--screenshot <path>] [--observation <text>]` appends one JSON line to `<phase-dir>/ooda-log.jsonl` (`{step, attempt, action, args, exit, screenshot|null, observation}`). `--attempt` is required; steps must be strictly monotonic within an attempt (a rerun is a new attempt at step 1 — attempts accumulate, never overwrite). Actions: browser (`aria|shot|click|scroll|type`) and image (`crop|zoom|label|nup|compose` — the last five log evidence derivatives made with `lib/edit-image.py` / `lib/render-html.js`; frame-producing actions require `--screenshot`). Corrupt logs or sequence gaps fail loudly (exit 2). No wall-clock reads, no randomness.
|
|
22
|
+
- `write-ooda-verdict.js` — deterministic writer for the OODA terminal verdict (2026-09-14, append-only ledger 2026-09-15): `node write-ooda-verdict.js --dir <phase-dir> --attempt <id> --verdict <PASS|FAIL|NOT_POSSIBLE> --summary <text> --expected <text> --actual <text> --missing <json-array>` writes `<phase-dir>/verdict.json` (the latest verdict) and appends one JSON line to `<phase-dir>/verdicts.jsonl` — the append-only ledger: every attempt's verdict is preserved with a mechanical `seq`, never overwritten; corrupt or non-contiguous ledgers fail loudly. Exit 2 on bad input. See `docs/ooda-report.md`.
|
|
23
|
+
- `serve-artifact.js` — local server for a built TS space for experiential QA (2026-09-14): serves `<space-dir>/client/dist` statically and dispatches POST `*/actions` to the compiled server actions with a locally-built Ctx. Prints `READY port=<n>` then serves until killed. Read-only w.r.t. the space directory. Fidelity: the served client and action handlers are the artifact's own built code; the Ctx is locally built (privileged handlers run from the space's own `server/dist/privileged.js` when present; blobs are stored in a per-run temp dir and served back at `/__blobs/<key>`); environment is inherited from the caller. It is not the hosted runtime — tasks that cannot be judged under it must report `NOT POSSIBLE: <reason>`.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// append-ooda-step.js — deterministic writer for the OODA report log.
|
|
2
|
+
//
|
|
3
|
+
// The QA/repro agent runs the see-act loop itself; after each step it logs
|
|
4
|
+
// what it did and what it observed. This script owns the schema so the agent
|
|
5
|
+
// never hand-writes JSON: it supplies the fields as flags, the script
|
|
6
|
+
// validates and appends one JSON line.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node append-ooda-step.js --log <path> --attempt <id> --step <n>
|
|
10
|
+
// --action <a> --exit <code>
|
|
11
|
+
// [--args <json>] [--screenshot <path>] [--observation <text>] [--ts <iso>]
|
|
12
|
+
//
|
|
13
|
+
// --attempt is the attempt/run identity (e.g. "1", "2" for successive QA
|
|
14
|
+
// loops; "repro-1" for a reproduce run). Steps are strictly monotonic PER
|
|
15
|
+
// ATTEMPT: the first step of an attempt is 1 and each following step is
|
|
16
|
+
// exactly previous+1. Retries never overwrite — they start a new attempt,
|
|
17
|
+
// so the log preserves every attempt's full sequence. A step that is not
|
|
18
|
+
// exactly previous+1 fails loudly (exit 2) and is not appended.
|
|
19
|
+
//
|
|
20
|
+
// Actions: aria, shot, click, scroll, type (see-act driver) plus the image
|
|
21
|
+
// tools: crop, zoom, label, nup (edit-image.py) and compose (render-html.js).
|
|
22
|
+
// Every action that produces a frame must carry --screenshot; a captured
|
|
23
|
+
// frame that is not logged is invisible to everyone after the agent.
|
|
24
|
+
//
|
|
25
|
+
// The log is <phase-dir>/ooda-log.jsonl, one object per line:
|
|
26
|
+
// {step, attempt, action, args, exit, screenshot|null, observation, ts?}
|
|
27
|
+
// Together with the archived screenshots and verdicts.jsonl, the log is the
|
|
28
|
+
// task's OODA report — the complete experiential record of the session.
|
|
29
|
+
//
|
|
30
|
+
// Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
|
|
31
|
+
// randomness; ts comes only from --ts (the agent's own clock) and is omitted
|
|
32
|
+
// when not passed.
|
|
33
|
+
"use strict";
|
|
34
|
+
|
|
35
|
+
const { appendFileSync, mkdirSync, readFileSync, existsSync } = require("node:fs");
|
|
36
|
+
const { dirname, resolve } = require("node:path");
|
|
37
|
+
|
|
38
|
+
const ACTIONS = {
|
|
39
|
+
aria: 1, shot: 1, click: 1, scroll: 1, type: 1,
|
|
40
|
+
crop: 1, zoom: 1, label: 1, nup: 1, compose: 1,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Actions that produce a frame the agent must have looked at.
|
|
44
|
+
const FRAME_ACTIONS = { shot: 1, crop: 1, zoom: 1, label: 1, nup: 1, compose: 1 };
|
|
45
|
+
|
|
46
|
+
function fail(msg) {
|
|
47
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const a = argv[i];
|
|
55
|
+
if (a === "--log") out.log = argv[++i];
|
|
56
|
+
else if (a === "--attempt") out.attempt = argv[++i];
|
|
57
|
+
else if (a === "--step") out.step = argv[++i];
|
|
58
|
+
else if (a === "--action") out.action = argv[++i];
|
|
59
|
+
else if (a === "--exit") out.exit = argv[++i];
|
|
60
|
+
else if (a === "--args") out.args = argv[++i];
|
|
61
|
+
else if (a === "--screenshot") out.screenshot = argv[++i];
|
|
62
|
+
else if (a === "--observation") out.observation = argv[++i];
|
|
63
|
+
else if (a === "--ts") out.ts = argv[++i];
|
|
64
|
+
else fail("unknown flag: " + a);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Read the existing log's max step for this attempt. Malformed lines fail
|
|
70
|
+
// loudly — a corrupt log is evidence of a broken run, not something to skip.
|
|
71
|
+
function maxStepForAttempt(logPath, attempt) {
|
|
72
|
+
if (!existsSync(logPath)) return 0;
|
|
73
|
+
const raw = readFileSync(logPath, "utf8");
|
|
74
|
+
let max = 0;
|
|
75
|
+
let seen = false;
|
|
76
|
+
for (const line of raw.split("\n")) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
let entry;
|
|
79
|
+
try {
|
|
80
|
+
entry = JSON.parse(line);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
fail("existing log is corrupt (unparseable line): " + logPath);
|
|
83
|
+
}
|
|
84
|
+
if (entry && entry.attempt === attempt) {
|
|
85
|
+
seen = true;
|
|
86
|
+
if (!Number.isInteger(entry.step) || entry.step < 1) {
|
|
87
|
+
fail("existing log is corrupt (bad step for attempt " + attempt + "): " + logPath);
|
|
88
|
+
}
|
|
89
|
+
if (entry.step > max) max = entry.step;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// An attempt with entries must have a contiguous 1..max sequence; a gap
|
|
93
|
+
// means the log was hand-edited or a step was lost — fail loudly.
|
|
94
|
+
if (seen) {
|
|
95
|
+
const steps = new Set();
|
|
96
|
+
for (const line of raw.split("\n")) {
|
|
97
|
+
if (!line.trim()) continue;
|
|
98
|
+
const entry = JSON.parse(line);
|
|
99
|
+
if (entry && entry.attempt === attempt) steps.add(entry.step);
|
|
100
|
+
}
|
|
101
|
+
for (let n = 1; n <= max; n++) {
|
|
102
|
+
if (!steps.has(n)) fail("existing log has a gap at step " + n + " for attempt " + attempt + ": " + logPath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return max;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function main() {
|
|
109
|
+
const args = parseArgs(process.argv.slice(2));
|
|
110
|
+
if (!args.log) fail("missing --log <path>");
|
|
111
|
+
if (args.attempt === undefined || String(args.attempt).trim() === "") {
|
|
112
|
+
fail("missing --attempt <id> (the attempt/run identity, e.g. \"1\", \"2\", \"repro-1\")");
|
|
113
|
+
}
|
|
114
|
+
if (args.step === undefined) fail("missing --step <n>");
|
|
115
|
+
if (!args.action) fail("missing --action <aria|shot|click|scroll|type|crop|zoom|label|nup|compose>");
|
|
116
|
+
if (args.exit === undefined) fail("missing --exit <code>");
|
|
117
|
+
|
|
118
|
+
const attempt = String(args.attempt).trim();
|
|
119
|
+
const step = parseInt(args.step, 10);
|
|
120
|
+
if (!Number.isFinite(step) || step < 1 || String(step) !== String(args.step).trim()) {
|
|
121
|
+
fail("--step must be a positive integer, got: " + args.step);
|
|
122
|
+
}
|
|
123
|
+
if (!ACTIONS[args.action]) fail("unknown --action: " + args.action);
|
|
124
|
+
const exit = parseInt(args.exit, 10);
|
|
125
|
+
if (!Number.isFinite(exit)) fail("--exit must be an integer, got: " + args.exit);
|
|
126
|
+
|
|
127
|
+
let parsedArgs = {};
|
|
128
|
+
if (args.args !== undefined) {
|
|
129
|
+
try {
|
|
130
|
+
parsedArgs = JSON.parse(args.args);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
fail("--args must be valid JSON: " + e.message);
|
|
133
|
+
}
|
|
134
|
+
if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
|
|
135
|
+
fail("--args must be a JSON object");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (FRAME_ACTIONS[args.action] && !args.screenshot) {
|
|
140
|
+
fail("--action " + args.action + " produces a frame: --screenshot is required (log the frame you looked at)");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const logPath = resolve(args.log);
|
|
144
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const expected = maxStepForAttempt(logPath, attempt) + 1;
|
|
147
|
+
if (step !== expected) {
|
|
148
|
+
fail("--step must be exactly previous+1 for attempt \"" + attempt + "\": expected " + expected + ", got " + step +
|
|
149
|
+
" (retries start a NEW --attempt; they never renumber or overwrite)");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entry = {
|
|
153
|
+
step: step,
|
|
154
|
+
attempt: attempt,
|
|
155
|
+
action: args.action,
|
|
156
|
+
args: parsedArgs,
|
|
157
|
+
exit: exit,
|
|
158
|
+
screenshot: args.screenshot ? resolve(args.screenshot) : null,
|
|
159
|
+
observation: args.observation || "",
|
|
160
|
+
};
|
|
161
|
+
if (args.ts) entry.ts = args.ts;
|
|
162
|
+
|
|
163
|
+
appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
164
|
+
process.stdout.write(JSON.stringify({ ok: true, log: logPath, attempt: attempt, step: step }) + "\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// build-readback-request.js — deterministic builder for the parent publish
|
|
3
|
+
// content read-back inspection request.
|
|
4
|
+
//
|
|
5
|
+
// The parent (tick worker) never hand-writes the inspection prompt: this
|
|
6
|
+
// script builds the EXACT request from the merge commit's diff, so the
|
|
7
|
+
// read-back asks about the real expected change, not an LLM's paraphrase.
|
|
8
|
+
// Output is JSON on stdout: { verbatim_request }.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node build-readback-request.js --repo-path <path> --commit <sha>
|
|
12
|
+
// --base <sha> --task-id <uuid> --slug <artifact-slug>
|
|
13
|
+
// [--build-agent-id <uuid>]
|
|
14
|
+
//
|
|
15
|
+
// --base is the previously-stamped provenance source_commit (the tree the
|
|
16
|
+
// last verification certified), or the empty-tree sha
|
|
17
|
+
// 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first publish with no
|
|
18
|
+
// prior stamp. It is REQUIRED because the merge commit's first parent is
|
|
19
|
+
// NOT necessarily the previously-published tree: push-time reconcile
|
|
20
|
+
// merges (2026-09-14, task 0c53af4e) put the task's own changes behind an
|
|
21
|
+
// intermediate merge, so `commit^1..commit` covers only the reconcile
|
|
22
|
+
// delta and the request would certify content it never checked.
|
|
23
|
+
//
|
|
24
|
+
// Exit codes: 0 ok · 2 usage/validation · 1 git/diff failure.
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
29
|
+
|
|
30
|
+
function arg(name) {
|
|
31
|
+
const i = process.argv.indexOf(name);
|
|
32
|
+
if (i < 0 || i + 1 >= process.argv.length) {
|
|
33
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: `${name} is required.` }) + "\n");
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
return process.argv[i + 1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const repoPath = arg("--repo-path");
|
|
40
|
+
const commit = arg("--commit");
|
|
41
|
+
const base = arg("--base");
|
|
42
|
+
const taskId = arg("--task-id");
|
|
43
|
+
const slug = arg("--slug");
|
|
44
|
+
const buildAgentId = process.argv.includes("--build-agent-id")
|
|
45
|
+
? arg("--build-agent-id") : null;
|
|
46
|
+
|
|
47
|
+
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
|
48
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: "commit must be a 40-char hex sha." }) + "\n");
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
if (!/^[0-9a-f]{40}$/.test(base)) {
|
|
52
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: "base must be a 40-char hex sha." }) + "\n");
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
// The publish delta is base..commit. Refuse a base that is not an ancestor:
|
|
56
|
+
// certifying a diff against an unrelated tree is worse than no request.
|
|
57
|
+
if (base !== EMPTY_TREE) {
|
|
58
|
+
try {
|
|
59
|
+
execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "usage", message: `base ${base} is not an ancestor of commit ${commit} — refusing to build a request that could certify an unrelated tree.` }) + "\n");
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let diff;
|
|
67
|
+
try {
|
|
68
|
+
diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
|
|
69
|
+
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "git", message: `git diff failed: ${e.message}` }) + "\n");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
if (!diff.trim()) {
|
|
76
|
+
process.stderr.write(JSON.stringify({ ok: false, error: "git", message: "empty diff for commit." }) + "\n");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Parse the diff into per-file added/removed lines (the contract the
|
|
81
|
+
// verifier will check the inspector's findings against).
|
|
82
|
+
const files = [];
|
|
83
|
+
let current = null;
|
|
84
|
+
for (const line of diff.split("\n")) {
|
|
85
|
+
if (line.startsWith("diff --git")) {
|
|
86
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
87
|
+
current = { path: m ? m[2] : "unknown", added: [], removed: [] };
|
|
88
|
+
files.push(current);
|
|
89
|
+
} else if (current && line.startsWith("+") && !line.startsWith("+++")) {
|
|
90
|
+
current.added.push(line.slice(1));
|
|
91
|
+
} else if (current && line.startsWith("-") && !line.startsWith("---")) {
|
|
92
|
+
current.removed.push(line.slice(1));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const buildIdLine = buildAgentId
|
|
97
|
+
? `Expected builder build agent_id: ${buildAgentId} (the artifact system's in-flight correlation ID for this publish attempt — not a durable post-completion identifier).\nReport the live build's agent_id as seen in artifact_status (or state explicitly that no build/agent_id is visible). If an expected agent_id is given above and the live one differs, say so exactly — the read-back may be inspecting a different build's output.\n\n`
|
|
98
|
+
: "";
|
|
99
|
+
|
|
100
|
+
const verbatim_request =
|
|
101
|
+
`Publish content read-back for task ${taskId}, merge commit ${commit}.
|
|
102
|
+
Expected change: the publish delta ${base}..${commit} (base = previously-stamped source commit, or the empty tree for a first publish).
|
|
103
|
+
The unified diff below was supposed to be applied to this artifact's source tree and deployed. Do NOT modify anything.
|
|
104
|
+
Do NOT rely on the builder's applied-changes report — it is derived from this same diff, so it cannot confirm the content. Read the artifact's CURRENT source directly.
|
|
105
|
+
|
|
106
|
+
${buildIdLine}UNIFIED DIFF (expected change):
|
|
107
|
+
\`\`\`diff
|
|
108
|
+
${diff}\`\`\`
|
|
109
|
+
|
|
110
|
+
For each file in the diff:
|
|
111
|
+
1. Read the file's CURRENT content in the artifact source tree.
|
|
112
|
+
2. Quote the exact current text of the regions around the changed lines.
|
|
113
|
+
3. For every added (+) line in the diff, state whether that exact line is PRESENT in the current source.
|
|
114
|
+
4. For every removed (-) line in the diff, state whether that exact line is ABSENT from the current source.
|
|
115
|
+
5. Report build/deploy health and the console error count.
|
|
116
|
+
|
|
117
|
+
MACHINE-READABLE FINDINGS (REQUIRED — the parent parses this block mechanically; if it is missing or malformed the verification FAILS CLOSED as blocked):
|
|
118
|
+
For each file, emit exactly these lines, one per changed line, with the verdict at the end of the line:
|
|
119
|
+
FILE: <path>
|
|
120
|
+
ADDED: <exact added line from the diff> :: PRESENT
|
|
121
|
+
ADDED: <exact added line from the diff> :: ABSENT
|
|
122
|
+
REMOVED: <exact removed line from the diff> :: PRESENT
|
|
123
|
+
REMOVED: <exact removed line from the diff> :: ABSENT
|
|
124
|
+
END_FILE
|
|
125
|
+
Emit one ADDED line per (+) diff line and one REMOVED line per (-) diff line — no more, no fewer. The verdict is PRESENT if that exact line occurs in the file's current source, ABSENT otherwise.
|
|
126
|
+
|
|
127
|
+
Return the per-file present/absent findings with the quoted observed lines. Do not modify anything.
|
|
128
|
+
This read-back feeds the parent content-verification protocol (docs/publish-verification.md): the parent stamps provenance only when every added line is present and every removed line is absent.`;
|
|
129
|
+
|
|
130
|
+
process.stdout.write(JSON.stringify({ verbatim_request, files_changed: files.map((f) => f.path) }, null, 2) + "\n");
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/compose-evidence-caption.js — deterministic QA-evidence caption composer.
|
|
3
|
+
//
|
|
4
|
+
// Screenshots delivered to chat can arrive out of context: the tick worker
|
|
5
|
+
// delivers them on its own cadence, detached from the run that produced them.
|
|
6
|
+
// This script builds the transition Eric asked for (2026-09-14): acknowledge
|
|
7
|
+
// the out-of-context arrival, state the problem the task addressed and the
|
|
8
|
+
// solution shipped, then hand off to the desktop/mobile attachments the
|
|
9
|
+
// caller appends AFTER this caption. Deterministic: every line is extracted
|
|
10
|
+
// mechanically from the Crew API, the task's events, and the merge commit —
|
|
11
|
+
// no LLM prose. The caller (seed/cron-body-template.md Step 6) prints this
|
|
12
|
+
// caption verbatim, then the two attachment lines, then logs delivery.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node lib/compose-evidence-caption.js --crew-home <home> \
|
|
16
|
+
// --task-id <id> --audit-dir <resolved-dir-name>
|
|
17
|
+
//
|
|
18
|
+
// Prints the caption to stdout. Exits non-zero on any missing input.
|
|
19
|
+
|
|
20
|
+
const { execFileSync } = require("node:child_process");
|
|
21
|
+
const path = require("node:path");
|
|
22
|
+
|
|
23
|
+
function usage(msg) {
|
|
24
|
+
process.stderr.write("compose-evidence-caption: " + msg + "\n");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
const args = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
if (argv[i].startsWith("--")) {
|
|
32
|
+
const k = argv[i].slice(2).replace(/-/g, "_");
|
|
33
|
+
args[k] = (argv[i + 1] && !argv[i + 1].startsWith("--")) ? argv[++i] : true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const crewHome = args.crew_home;
|
|
37
|
+
const taskId = args.task_id;
|
|
38
|
+
const auditDir = args.audit_dir;
|
|
39
|
+
if (!crewHome) usage("--crew-home is required");
|
|
40
|
+
if (!taskId) usage("--task-id is required");
|
|
41
|
+
if (!auditDir) usage("--audit-dir is required");
|
|
42
|
+
|
|
43
|
+
const CLI = path.join(__dirname, "crew-api.js");
|
|
44
|
+
|
|
45
|
+
function api(command, payload) {
|
|
46
|
+
const out = execFileSync("node", [CLI, "--crew-home", crewHome, command, "--json", JSON.stringify(payload)], {
|
|
47
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
return JSON.parse(out);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function gitSubject(repoPath, commit) {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync("git", ["-C", repoPath, "log", "-1", "--format=%s", commit], {
|
|
55
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000,
|
|
56
|
+
}).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function trimWords(s, max) {
|
|
63
|
+
s = (s || "").replace(/\s+/g, " ").trim();
|
|
64
|
+
if (s.length <= max) return s;
|
|
65
|
+
const cut = s.slice(0, max - 1);
|
|
66
|
+
const at = cut.lastIndexOf(" ");
|
|
67
|
+
return (at > 0 ? cut.slice(0, at) : cut) + "…";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let state;
|
|
71
|
+
try {
|
|
72
|
+
state = api("get-state", {});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
usage("cannot read crew state: " + (e.message || e));
|
|
75
|
+
}
|
|
76
|
+
const task = (state.tasks || []).find((t) => t.id === taskId);
|
|
77
|
+
if (!task) usage("task " + taskId + " not found");
|
|
78
|
+
|
|
79
|
+
let events = [];
|
|
80
|
+
try {
|
|
81
|
+
events = api("get-events", { task_id: taskId, limit: 100 }).events || [];
|
|
82
|
+
} catch (e) {
|
|
83
|
+
usage("cannot read task events: " + (e.message || e));
|
|
84
|
+
}
|
|
85
|
+
const notes = events.filter((e) => e.type === "note");
|
|
86
|
+
|
|
87
|
+
// QA verdict: the workflow's own exact-prefix contract — the latest note
|
|
88
|
+
// whose message starts exactly "visual_verdict: PASS" / "visual_verdict: FAIL".
|
|
89
|
+
const verdictNote = [...notes].reverse()
|
|
90
|
+
.find((e) => /^visual_verdict:\s*(PASS|FAIL)/.test(e.message || ""));
|
|
91
|
+
const verdict = verdictNote
|
|
92
|
+
? verdictNote.message.replace(/^visual_verdict:\s*/, "").trim()
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
// Provenance: this task's own `publish: verified` event first; the global
|
|
96
|
+
// provenance record only when it belongs to this task.
|
|
97
|
+
let commit = null;
|
|
98
|
+
const verifiedNote = [...notes].reverse()
|
|
99
|
+
.find((e) => (e.message || "").includes("publish: verified"));
|
|
100
|
+
if (verifiedNote) {
|
|
101
|
+
const m = /\b([0-9a-f]{40})\b/.exec(verifiedNote.message);
|
|
102
|
+
if (m) commit = m[1];
|
|
103
|
+
}
|
|
104
|
+
if (!commit) {
|
|
105
|
+
try {
|
|
106
|
+
const prov = api("get-provenance", {}).provenance;
|
|
107
|
+
if (prov && prov.task_id === taskId && /^[0-9a-f]{40}$/.test(prov.source_commit || "")) {
|
|
108
|
+
commit = prov.source_commit;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// best-effort; absence is reported honestly below
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The solution shipped: the merge commit's subject line; fall back to the
|
|
116
|
+
// task title when the commit is unknown or unreadable.
|
|
117
|
+
let solution = "";
|
|
118
|
+
if (commit) {
|
|
119
|
+
const proj = (state.projects || []).find((p) => p.id === task.project);
|
|
120
|
+
if (proj && proj.repo_path) solution = gitSubject(proj.repo_path, commit);
|
|
121
|
+
}
|
|
122
|
+
if (!solution) solution = task.title;
|
|
123
|
+
|
|
124
|
+
const problem = trimWords(task.description || task.title, 280);
|
|
125
|
+
// The task's own run record is authoritative for the honest label: a
|
|
126
|
+
// `baseline: none` note means QA ran without the visual protocol.
|
|
127
|
+
const visualUnavailable = notes.some((e) => /baseline:\s*none/i.test(e.message || ""));
|
|
128
|
+
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push("These screenshots are dropping in out of context — quick transition.");
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push("**" + task.title + "**");
|
|
133
|
+
lines.push("Problem: " + problem);
|
|
134
|
+
lines.push("Shipped: " + trimWords(solution, 160) +
|
|
135
|
+
(commit ? " (commit `" + commit.slice(0, 12) + "`, parent-verified)" : " (provenance not yet stamped)"));
|
|
136
|
+
lines.push("Evidence: desktop + mobile captures below, taken by the platform audit harness after deploy (`" + auditDir + "`)." +
|
|
137
|
+
(verdict ? " QA verdict: " + trimWords(verdict, 200) + "." : " No QA verdict recorded."));
|
|
138
|
+
if (visualUnavailable) {
|
|
139
|
+
lines.push("Note: the visual protocol is unavailable — these are the post-deploy capture plus QA's mechanical checks, not a QA visual inspection.");
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(lines.join("\n") + "\n");
|