muse-crew 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +5 -3
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +13 -0
- package/docs/state-boundary-spec.md +37 -0
- package/lib/AGENTS.md +4 -1
- package/lib/advance-publish-base.js +250 -0
- package/lib/classify-surface.js +109 -0
- package/lib/compose-evidence-caption.js +55 -7
- package/lib/compute-publish-diff.js +145 -0
- package/lib/crew-api.js +34 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +5 -4
- package/workflows/bugfix.js +58 -65
- package/workflows/chore.js +58 -65
- package/workflows/crew-dispatch.js +21 -6
- package/workflows/crew-init.js +50 -0
- package/workflows/standard.js +58 -65
package/API.md
CHANGED
|
@@ -385,13 +385,15 @@ Register a new project.
|
|
|
385
385
|
| `id` | slug | yes | Unique project identifier |
|
|
386
386
|
| `display_name` | string (1–120) | yes | |
|
|
387
387
|
| `repo_path` | string (1–1000) | yes | Absolute path to the project's Git repo |
|
|
388
|
-
| `deploy_type` | `npm` · `artifact` · `vercel` · empty string | yes | Publish target: `npm` publishes the package to the npm registry, `artifact` rebuilds the live artifact, `vercel` is not yet implemented (blocks the task), empty skips the Publish phase |
|
|
389
|
-
| `deploy_slug` | slug or null | no | Required when `deploy_type` is `artifact` |
|
|
388
|
+
| `deploy_type` | `npm` · `artifact` · `vercel` · empty string | yes | Publish target: `npm` publishes the package to the npm registry, `artifact` rebuilds the live artifact, `vercel` is not yet implemented (blocks the task), empty skips the Publish phase. When omitted and the classified surface is `artifact`, defaults to `artifact` (see surface classification below). |
|
|
389
|
+
| `deploy_slug` | slug or null | no | Required when `deploy_type` is `artifact`. When `deploy_type` is auto-defaulted to `artifact`, filled from the repo's `space.json` slug when present; explicitly chosen `deploy_type`/`deploy_slug` values are never overwritten. |
|
|
390
390
|
| `description` | string (≤ 3000) | no | |
|
|
391
391
|
| `simultaneity` | integer (1–100) | no | Max concurrent tasks; defaults to 2 |
|
|
392
392
|
| `quiesced` | boolean | no | Start paused; defaults to false |
|
|
393
393
|
| `visual_protocol` | boolean or null | no | Tri-state: `null` = inherit crew default (off), `true` = enable for this project, `false` = explicit off. Defaults to null. |
|
|
394
|
-
| `environment_type` | `artifact` · `terminal` · null | no | User-facing surface for experiential QA routing: `artifact` = a rendered web UI (Hazel drives it with the see-act browser loop), `terminal` = a CLI (Hazel drives it herself, keeping transcripts). `null` = unclassified: no experiential QA. `deploy_type` names the deployment target, but `deploy_type: "artifact"` remains a legacy artifact-surface signal so pre-field projects keep today's experiential QA (the migration does not backfill the column); on conflicting config artifact wins.
|
|
394
|
+
| `environment_type` | `artifact` · `terminal` · null | no | User-facing surface for experiential QA routing: `artifact` = a rendered web UI (Hazel drives it with the see-act browser loop), `terminal` = a CLI (Hazel drives it herself, keeping transcripts). `null` = unclassified: no experiential QA. `deploy_type` names the deployment target, but `deploy_type: "artifact"` remains a legacy artifact-surface signal so pre-field projects keep today's experiential QA (the migration does not backfill the column); on conflicting config artifact wins. Omitted means auto-classify from `repo_path` (see surface classification below); an explicit value, including explicit `null`, always wins. |
|
|
395
|
+
|
|
396
|
+
**Surface classification (repo-derived defaults):** when `repo_path` is provided and `environment_type` is omitted, `create-project` mechanically classifies the repo at registration via `lib/classify-surface.js` — `space.json` present → `artifact`; else a non-empty `package.json` `bin` → `terminal`; otherwise unclassified (`null`). The classifier never throws: bad paths and malformed files classify as unclassified. Classification only fills omitted fields — an explicitly supplied `environment_type` (including explicit `null`) always wins, and deploy defaults follow the *effective* surface, so an explicit `"terminal"` never triggers artifact deploy defaults.
|
|
395
397
|
|
|
396
398
|
### `updateproject`
|
|
397
399
|
|
package/docs/guide.md
CHANGED
|
@@ -261,7 +261,7 @@ Every 15 minutes, the `crew-poll` cron fires:
|
|
|
261
261
|
|
|
262
262
|
7. **Task completes** — when all phases finish, the dispatcher marks the task `done` on the next tick.
|
|
263
263
|
|
|
264
|
-
Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch.
|
|
264
|
+
Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch. The writer echoes the appended line to stdout (2026-09-17, rooms #12–#14): the fire-and-forget agent call carries no schema, and the platform's JSON-candidate scan throws on an empty schema-less result — so the writer speaks the same non-empty shape as every other fire-and-forget call in the dispatcher.
|
|
265
265
|
|
|
266
266
|
### Failure handling and retry
|
|
267
267
|
|
|
@@ -106,6 +106,19 @@ request builder (`lib/build-readback-request.js`, `--base`) use the identical
|
|
|
106
106
|
base: the previously-stamped provenance, or the empty tree for a genuine
|
|
107
107
|
first publish. Request builder and verifier never disagree on the base.
|
|
108
108
|
|
|
109
|
+
**Diff transport (room #14, 2026-09-17):** the diff is computed by the
|
|
110
|
+
deterministic `lib/compute-publish-diff.js` (pinned per-run alongside the
|
|
111
|
+
other lifecycle files), which writes the raw diff bytes to
|
|
112
|
+
`$CREW_HOME/.publish-diffs/<taskId>[-rN].diff` and returns only a small JSON
|
|
113
|
+
summary (`sha256`, `changed_lines` = added+removed, `has_binary`,
|
|
114
|
+
`has_rename`). The LLM never carries diff bytes — an earlier shape asked an
|
|
115
|
+
agent to return the raw diff as a JSON string field, and the JSON ferry
|
|
116
|
+
dropped a valid 700-line diff ("Publish diff parsed to zero files"). The
|
|
117
|
+
trigger agent verifies the file with `sha256sum` before any `artifact_edit`
|
|
118
|
+
call and pastes the verified content into the edit. The 200 changed-line
|
|
119
|
+
budget counts added+removed lines from the summary, never raw diff output
|
|
120
|
+
lines (context lines inflated the old count ~2x).
|
|
121
|
+
|
|
109
122
|
## The shape: code detects, the tick ferries, code certifies
|
|
110
123
|
|
|
111
124
|
A standalone verification workflow cannot work with the async inspection
|
|
@@ -182,3 +182,40 @@ setup, accurate message, idempotency, non-repo no-op, missing scaffold
|
|
|
182
182
|
throws, missing identity throws, composer refusal on dirty `.gitignore`,
|
|
183
183
|
declined consent leaves `.gitignore` untouched; structural: crew-init
|
|
184
184
|
calls the composer).
|
|
185
|
+
|
|
186
|
+
## 9. Publish base advances past the setup scaffold (0.11.2)
|
|
187
|
+
|
|
188
|
+
**Defect (room #14, 2026-09-17):** `create-project` stamps
|
|
189
|
+
provenance `source_commit` at the pre-scaffold repo HEAD; the scaffold
|
|
190
|
+
commit lands on top. The first task's publish diff then carries the whole
|
|
191
|
+
scaffold (517 insertions in room #14) and blows the 200 changed-line
|
|
192
|
+
diff-transport budget. The scaffold is crew-owned infrastructure the
|
|
193
|
+
dashboard artifact never consumes, so the correct publish base is the
|
|
194
|
+
scaffold commit itself.
|
|
195
|
+
|
|
196
|
+
**Contract:** after the scaffold commit, crew-init runs
|
|
197
|
+
`lib/advance-publish-base.js`, which moves `source_commit` to the scaffold
|
|
198
|
+
commit only when every condition proves safety:
|
|
199
|
+
|
|
200
|
+
- this init created the project (`--project-created`): the stamp is ours,
|
|
201
|
+
never a real publish's;
|
|
202
|
+
- the scaffold commit's parent is exactly the stamped base: nothing (an
|
|
203
|
+
un-published task commit, a human push) slipped between the stamp and
|
|
204
|
+
the scaffold. An intermediate commit skips — the base still covers the
|
|
205
|
+
un-published work;
|
|
206
|
+
- the scaffold commit touches only crew-owned paths (`.orchestration/`,
|
|
207
|
+
`.gitignore`): a foreign path skips, so user content is never dropped
|
|
208
|
+
from a publish diff;
|
|
209
|
+
- no provenance + root scaffold commit → stamp fresh (the empty-repo-at-
|
|
210
|
+
create case); no provenance + non-root scaffold → skip (unverified
|
|
211
|
+
history is not claimed).
|
|
212
|
+
|
|
213
|
+
Every other case skips without touching provenance. A base-pointer move is
|
|
214
|
+
not a publish: `published_at` is preserved, and the result is read back
|
|
215
|
+
and verified exact before returning. The call is best-effort from
|
|
216
|
+
crew-init: an advance failure is logged loudly and init continues with the
|
|
217
|
+
stamped base.
|
|
218
|
+
|
|
219
|
+
**Tests:** `tests/advance-publish-base.test.js` (behavioral: advance,
|
|
220
|
+
idempotency, all fail-closed skips, fresh stamp, usage errors; structural:
|
|
221
|
+
crew-init wiring).
|
package/lib/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ 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 QA verdict, 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. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all
|
|
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 QA verdict, 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. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all, the newest `baseline:` note is `baseline: none`, AND captures exist (a `baseline: none` note means no baseline captures to compare against — it never overrules a real QA verdict; the note's premise is the post-deploy capture, so without captures it would fabricate evidence again). Capture guard (2026-09-17, room #14): the composer resolves the audit dir itself (`<home>/workspace/ts-spaces/<deploy_slug>/audits/<auditDir>/`, the same root crew-api.js and readback-disk.js resolve) and verifies `screenshot.png` + `screenshot-mobile.png` are files before claiming "desktop + mobile captures below" — a bare `--audit-dir` name is never trusted. Missing deploy_slug, missing dir, or missing PNGs gets the honest no-captures opener and Evidence line (terminal surfaces named explicitly: "terminal surface — QA judges the CLI, not pixels"); the caller only appends attachment lines when its own freshness check passes. "Latest" is by event timestamp, sorted explicitly. Exits non-zero on missing input.
|
|
18
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
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
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.
|
|
@@ -32,3 +32,6 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
32
32
|
- `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's main checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
|
|
33
33
|
- `commit-scaffold.js` — deterministic setup-scaffold commit (2026-09-18): stages only `.orchestration/` (+ `.gitignore` with `--gitignore`) and commits with a message naming exactly what was staged. Idempotent (`nothing-to-commit`, never an empty commit); not-a-git-repo is a clean no-op; missing `.orchestration/` and commit failures (e.g. no git identity) throw loudly — the commit belongs to the user, so no identity is ever invented here. Exports `isPathDirty` (porcelain check for one path) for the composer's pre-mutation guard. CLI: `--repo <path> [--gitignore]`.
|
|
34
34
|
- `sample-project.js` — optional sample-project setup (2026-09-17): extracts `seed/sample-project.tar.gz` (stripping the `sample-project/` prefix), `git init`, initial commit. No remote required. Registration happens via the existing `create-project` action — no sample-specific endpoint. CLI: `--tarball <path> --dest <dir> --name <project-name>`.
|
|
35
|
+
- `compute-publish-diff.js` — deterministic publish-diff computer (2026-09-18, room #14): replaces the agent-ferried `git diff` JSON string field (the JSON ferry dropped a valid 700-line diff — "Publish diff parsed to zero files"). Runs `git diff <base> <HEAD>` via argv (no shell), writes the raw diff bytes to `--out`, and prints only a small JSON summary (`commit`, `base`, `diff_file`, `sha256`, `bytes`, `file_count`, `changed_lines` = added+removed, never raw diff output lines, `files`, `has_binary`, `has_rename`). Empty-tree base is the first-publish ancestor (hardcoded, never `merge-base` on the empty tree); non-ancestor is semantic JSON (`ok:false, error:"not-ancestor"`, exit 0); usage errors exit 2 with nothing on stdout. Pinned per-run alongside the other lifecycle files (`COMPUTE_DIFF` in `PIN_BASENAMES`) so a mid-run release swap cannot change it under the workflow.
|
|
36
|
+
- `classify-surface.js` — deterministic project-surface classifier (2026-09-18, room #14 defect): a pure, never-throwing scan of the registered repo — `space.json` present → `artifact` surface (deploy_slug suggestion = `space.json`'s valid slug, verbatim — presence alone still means "web app"), `package.json` with a non-empty `bin` → `terminal` surface, otherwise null (unclassified); artifact wins conflicts (the deployed artifact is what users see, same rule as workflows/standard.js). CLI `--repo-path` prints one JSON object (`{surface, deploy_slug, signals}`), always exit 0 (classification failure is a null result, exit 2 only for usage). `create-project` fills omitted `environment_type`/`deploy_type`/`deploy_slug` from the classification; explicit values (including explicit null) always win and drive the effective surface.
|
|
37
|
+
- `advance-publish-base.js` — publish-base advance past the setup scaffold (2026-09-18, room #14): `create-project` stamps provenance at the pre-scaffold repo HEAD, then crew-init commits `.orchestration/` (+ the consented `.gitignore`) on top — the first task's publish diff would carry the whole scaffold (517 insertions) and blow the 200-line diff-transport budget. The script moves `source_commit` to the scaffold commit only when `--project-created` (the stamp is this init's, never a real publish's), the scaffold commit's parent is exactly the stamped base, and the scaffold touches only crew-owned paths (`.orchestration/`, `.gitignore`); no provenance + root scaffold commit stamps fresh. Every other case skips without touching provenance; `published_at` is preserved on advance. Called from crew-init.js (best-effort, non-fatal). CLI: `--crew-home <path> --repo <path> --scaffold-sha <sha> [--project-created]`.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Advance the publish base past crew-init's scaffold commit (2026-09-18).
|
|
3
|
+
//
|
|
4
|
+
// Room #14 (2026-09-17): create-project stamps provenance.source_commit at the
|
|
5
|
+
// pre-scaffold repo HEAD; crew-init then commits the .orchestration/ scaffold
|
|
6
|
+
// on top. The first task's publish diff then carries the whole scaffold (517
|
|
7
|
+
// insertions in room #14) and blows the 200-line diff-transport budget. The
|
|
8
|
+
// scaffold is crew-owned infrastructure the dashboard artifact never consumes,
|
|
9
|
+
// so the correct publish base is the scaffold commit itself.
|
|
10
|
+
//
|
|
11
|
+
// This script moves provenance.source_commit to the scaffold commit only when
|
|
12
|
+
// every fail-closed condition holds:
|
|
13
|
+
// 1. --project-created: this init created the project, so the existing stamp
|
|
14
|
+
// is this run's initial stamp — never a real publish's.
|
|
15
|
+
// 2. The scaffold commit's parent IS the stamped source_commit: nothing
|
|
16
|
+
// slipped between the stamp and the scaffold, so no un-published task
|
|
17
|
+
// work can be silently dropped from a future diff.
|
|
18
|
+
// 3. Every path changed by the scaffold commit is crew-owned
|
|
19
|
+
// (.orchestration/ or .gitignore).
|
|
20
|
+
// 4. Special case: no provenance was ever stamped (empty repo at create
|
|
21
|
+
// time) and the scaffold commit is the root commit -> stamp fresh.
|
|
22
|
+
// Anything else yields { ok: true, action: "skipped", reason } — never an
|
|
23
|
+
// error, never a clobber. stdout carries exactly one JSON object; operational
|
|
24
|
+
// failures go to stderr with a non-zero exit (the caller treats failure as
|
|
25
|
+
// best-effort: init continues with the stamped base).
|
|
26
|
+
//
|
|
27
|
+
// No wall-clock reads, no randomness — the published_at on a fresh stamp is
|
|
28
|
+
// left to crew-api's default; on an advance the existing published_at is
|
|
29
|
+
// preserved (a base-pointer move is not a publish).
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { readlinkSync } from "node:fs";
|
|
33
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
38
|
+
const RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
|
|
39
|
+
|
|
40
|
+
function usage() {
|
|
41
|
+
console.error(
|
|
42
|
+
"Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --scaffold-sha <sha> --project-created"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgv(argv) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === "--crew-home") out.crewHome = argv[++i];
|
|
51
|
+
else if (a === "--repo") out.repo = argv[++i];
|
|
52
|
+
else if (a === "--scaffold-sha") out.scaffoldSha = argv[++i];
|
|
53
|
+
else if (a === "--project-created") out.projectCreated = true;
|
|
54
|
+
else {
|
|
55
|
+
console.error("unknown argument: " + a);
|
|
56
|
+
usage();
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function git(repo, args) {
|
|
64
|
+
return execFileSync("git", ["-C", repo].concat(args), {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
timeout: 15000,
|
|
67
|
+
}).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function crewApi(crewHome, command, jsonArg) {
|
|
71
|
+
const apiPath = join(HERE, "crew-api.js");
|
|
72
|
+
const args = ["--crew-home", crewHome, command];
|
|
73
|
+
if (jsonArg !== undefined) args.push("--json", jsonArg);
|
|
74
|
+
const out = execFileSync("node", [apiPath].concat(args), {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 15000,
|
|
77
|
+
}).trim();
|
|
78
|
+
return JSON.parse(out);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveActiveRelease(crewHome) {
|
|
82
|
+
// Mirrors lib/crew-api.js resolveActiveRelease minus the self-path check
|
|
83
|
+
// (this script is caller-pinned, not release-pinned).
|
|
84
|
+
const target = readlinkSync(join(crewHome, "current"));
|
|
85
|
+
return basename(resolve(crewHome, target));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isCrewOwnedPath(p) {
|
|
89
|
+
return p === ".gitignore" || p === ".orchestration" || p.startsWith(".orchestration/");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function skip(reason) {
|
|
93
|
+
console.log(JSON.stringify({ ok: true, action: "skipped", reason }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function main() {
|
|
97
|
+
const { crewHome, repo, scaffoldSha, projectCreated } = parseArgv(
|
|
98
|
+
process.argv.slice(2)
|
|
99
|
+
);
|
|
100
|
+
if (!crewHome || !repo || !scaffoldSha) {
|
|
101
|
+
usage();
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
if (!SHA_RE.test(scaffoldSha)) {
|
|
105
|
+
console.error("scaffold-sha is not a 40-char hex sha");
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
if (!projectCreated) {
|
|
109
|
+
skip("project was not created by this init — provenance left untouched");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let provenance;
|
|
114
|
+
try {
|
|
115
|
+
provenance = crewApi(crewHome, "get-provenance").provenance;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error("get-provenance failed: " + (e.message || e));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The scaffold commit's parent: empty output means S is the root commit.
|
|
122
|
+
let parent;
|
|
123
|
+
try {
|
|
124
|
+
const parents = git(repo, ["rev-list", "--parents", "-n", "1", scaffoldSha]).split(/\s+/);
|
|
125
|
+
parent = parents.length > 1 ? parents[1] : null;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error("rev-list failed: " + (e.message || e));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let changedPaths;
|
|
132
|
+
try {
|
|
133
|
+
changedPaths = git(repo, ["diff-tree", "--no-commit-id", "--name-only", "-r", scaffoldSha])
|
|
134
|
+
.split("\n")
|
|
135
|
+
.map((l) => l.trim())
|
|
136
|
+
.filter((l) => l.length > 0);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error("diff-tree failed: " + (e.message || e));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const foreign = changedPaths.filter((p) => !isCrewOwnedPath(p));
|
|
142
|
+
if (foreign.length > 0) {
|
|
143
|
+
skip(
|
|
144
|
+
"scaffold commit touches non-crew-owned paths (" +
|
|
145
|
+
foreign.slice(0, 5).join(", ") +
|
|
146
|
+
") — provenance left untouched"
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (provenance === null || provenance === undefined) {
|
|
152
|
+
// No stamp ever (empty repo at create time): stamp fresh only if the
|
|
153
|
+
// scaffold commit is the root commit — anything else means history we
|
|
154
|
+
// did not create, and we do not invent a base over it.
|
|
155
|
+
if (parent !== null) {
|
|
156
|
+
skip("no provenance stamped and scaffold commit is not the root commit — provenance left untouched");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let crewRelease = null;
|
|
160
|
+
try {
|
|
161
|
+
crewRelease = resolveActiveRelease(crewHome);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
skip("no provenance stamped and the active crew release is unresolvable (" + (e.message || e) + ")");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!RELEASE_RE.test(crewRelease)) {
|
|
167
|
+
skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
171
|
+
source_commit: scaffoldSha,
|
|
172
|
+
crew_release: crewRelease,
|
|
173
|
+
}));
|
|
174
|
+
if (!stamped || stamped.ok !== true) {
|
|
175
|
+
console.error("set-provenance did not return ok");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
console.log(JSON.stringify({ ok: true, action: "stamped", to: scaffoldSha.slice(0, 12) }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const base = provenance.source_commit;
|
|
183
|
+
if (!SHA_RE.test(base || "")) {
|
|
184
|
+
skip("existing provenance.source_commit is not a 40-char hex sha — provenance left untouched");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (base === scaffoldSha) {
|
|
188
|
+
skip("provenance already points at the scaffold commit");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (parent !== base) {
|
|
192
|
+
skip(
|
|
193
|
+
"scaffold commit's parent (" +
|
|
194
|
+
String(parent || "root").slice(0, 12) +
|
|
195
|
+
") is not the stamped base (" +
|
|
196
|
+
base.slice(0, 12) +
|
|
197
|
+
") — provenance left untouched"
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let crewRelease = RELEASE_RE.test(provenance.crew_release || "")
|
|
203
|
+
? provenance.crew_release
|
|
204
|
+
: null;
|
|
205
|
+
if (!crewRelease) {
|
|
206
|
+
try {
|
|
207
|
+
const resolved = resolveActiveRelease(crewHome);
|
|
208
|
+
if (RELEASE_RE.test(resolved)) crewRelease = resolved;
|
|
209
|
+
} catch {
|
|
210
|
+
crewRelease = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!crewRelease) {
|
|
214
|
+
skip("no usable crew_release for the re-stamp — provenance left untouched");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
219
|
+
source_commit: scaffoldSha,
|
|
220
|
+
crew_release: crewRelease,
|
|
221
|
+
// A base-pointer move is not a publish: keep the existing published_at.
|
|
222
|
+
...(provenance.published_at ? { published_at: provenance.published_at } : {}),
|
|
223
|
+
}));
|
|
224
|
+
if (!stamped || stamped.ok !== true) {
|
|
225
|
+
console.error("set-provenance did not return ok");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
// Exact read-back: the stamp must match what we intended, or it is fiction
|
|
229
|
+
// (same discipline as lib/verify-publish.js).
|
|
230
|
+
const recheck = crewApi(crewHome, "get-provenance").provenance;
|
|
231
|
+
if (!recheck || recheck.source_commit !== scaffoldSha) {
|
|
232
|
+
console.error("post-stamp read-back mismatch: provenance does not point at the scaffold commit");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
console.log(
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
ok: true,
|
|
238
|
+
action: "advanced",
|
|
239
|
+
from: base.slice(0, 12),
|
|
240
|
+
to: scaffoldSha.slice(0, 12),
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
main();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error("advance-publish-base failed: " + (e.message || e));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// classify-surface.js — deterministic user-facing surface classifier.
|
|
3
|
+
//
|
|
4
|
+
// Room #14 (2026-09-17): the flashcards sample (a TS Space web app —
|
|
5
|
+
// space.json, client/, server/) was registered with environment_type=null
|
|
6
|
+
// and deploy_slug=null because the setup conversation never classified it —
|
|
7
|
+
// SETUP-EXPERIENCE.md doesn't even mention the step. Consequence: Capture
|
|
8
|
+
// skipped ("surface unclassified"), Publish had no artifact target, QA had
|
|
9
|
+
// no runnable surface — J1/J2 parked with "no build, deployed artifact, or
|
|
10
|
+
// runnable probe surface". The setup conversation is an agent; asking it to
|
|
11
|
+
// classify in prose is wording, not a mechanism.
|
|
12
|
+
//
|
|
13
|
+
// This script is the mechanism: a pure, deterministic repo scan. Signals:
|
|
14
|
+
// space.json present -> artifact surface (a Space web app).
|
|
15
|
+
// deploy_slug suggestion = space.json's slug
|
|
16
|
+
// (verbatim — the artifact system keys on it).
|
|
17
|
+
// Presence is the signal: an unparseable
|
|
18
|
+
// space.json still means "web app", just with
|
|
19
|
+
// no slug suggestion.
|
|
20
|
+
// package.json with a "bin" -> terminal surface (a CLI).
|
|
21
|
+
// otherwise -> null (unclassified — today's behavior).
|
|
22
|
+
// Artifact wins on contradictory signals (space.json + bin): the deployed
|
|
23
|
+
// artifact is what users see (same rule as workflows/standard.js).
|
|
24
|
+
//
|
|
25
|
+
// create-project fills OMITTED environment_type / deploy_type / deploy_slug
|
|
26
|
+
// from this classification; explicit values always win (including an
|
|
27
|
+
// explicit null environment_type, which means "unclassified").
|
|
28
|
+
//
|
|
29
|
+
// Fail-safe: any IO error, bad path, or unparseable file yields
|
|
30
|
+
// { surface: null, ... } — never a throw, never a misclassification.
|
|
31
|
+
//
|
|
32
|
+
// Usage: node lib/classify-surface.js --repo-path <path>
|
|
33
|
+
// Stdout: exactly one JSON object:
|
|
34
|
+
// { surface: "artifact"|"terminal"|null, deploy_slug: string|null, signals: [...] }
|
|
35
|
+
// Exit codes: 0 ok (always — classification failure is a null result) ·
|
|
36
|
+
// 2 usage/validation. On exit 2 nothing is printed to stdout.
|
|
37
|
+
|
|
38
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
function isDir(p) {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(p).isDirectory();
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson(p) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validSlug(s) {
|
|
59
|
+
return typeof s === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function classifySurface(repoPath) {
|
|
63
|
+
const signals = [];
|
|
64
|
+
if (typeof repoPath !== "string" || repoPath.length === 0 || !isDir(repoPath)) {
|
|
65
|
+
return { surface: null, deploy_slug: null, signals };
|
|
66
|
+
}
|
|
67
|
+
// Artifact: space.json marks a TS Space web app.
|
|
68
|
+
if (existsSync(join(repoPath, "space.json"))) {
|
|
69
|
+
signals.push("space.json");
|
|
70
|
+
const space = readJson(join(repoPath, "space.json"));
|
|
71
|
+
const slug = space && validSlug(space.slug) ? space.slug : null;
|
|
72
|
+
if (slug) signals.push("space.json:slug=" + slug);
|
|
73
|
+
return { surface: "artifact", deploy_slug: slug, signals };
|
|
74
|
+
}
|
|
75
|
+
// Terminal: package.json "bin" declares a CLI entrypoint.
|
|
76
|
+
const pkg = readJson(join(repoPath, "package.json"));
|
|
77
|
+
if (pkg && pkg.bin !== undefined && pkg.bin !== null) {
|
|
78
|
+
const hasBin =
|
|
79
|
+
typeof pkg.bin === "string"
|
|
80
|
+
? pkg.bin.length > 0
|
|
81
|
+
: typeof pkg.bin === "object" && Object.keys(pkg.bin).length > 0;
|
|
82
|
+
if (hasBin) {
|
|
83
|
+
signals.push("package.json:bin");
|
|
84
|
+
return { surface: "terminal", deploy_slug: null, signals };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { surface: null, deploy_slug: null, signals };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isMainModule = (() => {
|
|
91
|
+
try {
|
|
92
|
+
return process.argv[1] === fileURLToPath(import.meta.url);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
if (isMainModule) {
|
|
99
|
+
const i = process.argv.indexOf("--repo-path");
|
|
100
|
+
const repoPath = i >= 0 ? process.argv[i + 1] : null;
|
|
101
|
+
if (!repoPath) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
JSON.stringify({ ok: false, error: "usage", message: "--repo-path is required" }) + "\n"
|
|
104
|
+
);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
// Never throws: classifySurface is total over string inputs.
|
|
108
|
+
process.stdout.write(JSON.stringify(classifySurface(repoPath)) + "\n");
|
|
109
|
+
}
|
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
// caller appends AFTER this caption. Deterministic: every line is extracted
|
|
10
10
|
// mechanically from the Crew API, the task's events, and the merge commit —
|
|
11
11
|
// no LLM prose. The caller (seed/cron-body-template.md Step 6) prints this
|
|
12
|
-
// caption verbatim, then the two attachment lines
|
|
12
|
+
// caption verbatim, then the two attachment lines (only when the audit
|
|
13
|
+
// harness actually captured screenshots), then logs delivery.
|
|
14
|
+
//
|
|
15
|
+
// Capture guard (2026-09-17, room #14): the composer must SEE the captures
|
|
16
|
+
// before it claims them. It once fabricated "desktop + mobile captures
|
|
17
|
+
// below" for a terminal-surface task with no audit dir at all, and opened
|
|
18
|
+
// with "these screenshots are dropping in" when no screenshots existed. So
|
|
19
|
+
// it resolves the audit dir itself — <home>/workspace/ts-spaces/<deploy_slug>
|
|
20
|
+
// /audits/<auditDir>/, the same root lib/crew-api.js and lib/readback-disk.js
|
|
21
|
+
// resolve — and checks screenshot.png + screenshot-mobile.png are files.
|
|
22
|
+
// Missing deploy_slug, missing dir, or missing PNGs gets the honest
|
|
23
|
+
// no-captures line; terminal surfaces are named, not assumed.
|
|
13
24
|
//
|
|
14
25
|
// Usage:
|
|
15
26
|
// node lib/compose-evidence-caption.js --crew-home <home> \
|
|
@@ -19,6 +30,7 @@
|
|
|
19
30
|
|
|
20
31
|
const { execFileSync } = require("node:child_process");
|
|
21
32
|
const fs = require("node:fs");
|
|
33
|
+
const os = require("node:os");
|
|
22
34
|
const path = require("node:path");
|
|
23
35
|
|
|
24
36
|
function usage(msg) {
|
|
@@ -76,6 +88,22 @@ try {
|
|
|
76
88
|
}
|
|
77
89
|
const task = (state.tasks || []).find((t) => t.id === taskId);
|
|
78
90
|
if (!task) usage("task " + taskId + " not found");
|
|
91
|
+
const project = (state.projects || []).find((p) => p.id === task.project);
|
|
92
|
+
|
|
93
|
+
// Capture guard (2026-09-17, room #14): resolve the audit dir the audit
|
|
94
|
+
// harness actually writes and verify the captures exist before claiming
|
|
95
|
+
// them. A bare --audit-dir name is never trusted on its own.
|
|
96
|
+
function isFile(p) {
|
|
97
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
let auditPath = null;
|
|
100
|
+
if (project && project.deploy_slug) {
|
|
101
|
+
auditPath = path.join(os.homedir(), "workspace", "ts-spaces",
|
|
102
|
+
project.deploy_slug, "audits", auditDir);
|
|
103
|
+
}
|
|
104
|
+
const hasCaptures = !!(auditPath &&
|
|
105
|
+
isFile(path.join(auditPath, "screenshot.png")) &&
|
|
106
|
+
isFile(path.join(auditPath, "screenshot-mobile.png")));
|
|
79
107
|
|
|
80
108
|
let events = [];
|
|
81
109
|
try {
|
|
@@ -149,8 +177,7 @@ if (!commit) {
|
|
|
149
177
|
// task title when the commit is unknown or unreadable.
|
|
150
178
|
let solution = "";
|
|
151
179
|
if (commit) {
|
|
152
|
-
|
|
153
|
-
if (proj && proj.repo_path) solution = gitSubject(proj.repo_path, commit);
|
|
180
|
+
if (project && project.repo_path) solution = gitSubject(project.repo_path, commit);
|
|
154
181
|
}
|
|
155
182
|
if (!solution) solution = task.title;
|
|
156
183
|
|
|
@@ -169,16 +196,37 @@ const newestBaselineNote = notesNewest.find((e) => /^baseline:\s*/i.test(e.messa
|
|
|
169
196
|
const visualUnavailable = !verdict && !!newestBaselineNote &&
|
|
170
197
|
/^baseline:\s*none/i.test(newestBaselineNote.message || "");
|
|
171
198
|
|
|
199
|
+
const verdictLine = verdict
|
|
200
|
+
? " QA verdict: " + verdict.status + (verdict.summary ? " — " + trimWords(verdict.summary, 200) : "") + "."
|
|
201
|
+
: " No QA verdict recorded.";
|
|
202
|
+
|
|
172
203
|
const lines = [];
|
|
173
|
-
|
|
204
|
+
if (hasCaptures) {
|
|
205
|
+
lines.push("These screenshots are dropping in out of context — quick transition.");
|
|
206
|
+
} else {
|
|
207
|
+
// Honest opener: no screenshots are arriving, so the report says so up
|
|
208
|
+
// front instead of pretending they are dropping in.
|
|
209
|
+
lines.push("QA evidence for this task — no screenshots, so this is the whole report.");
|
|
210
|
+
}
|
|
174
211
|
lines.push("");
|
|
175
212
|
lines.push("**" + task.title + "**");
|
|
176
213
|
lines.push("Problem: " + problem);
|
|
177
214
|
lines.push("Shipped: " + trimWords(solution, 160) +
|
|
178
215
|
(commit ? " (commit `" + commit.slice(0, 12) + "`, parent-verified)" : " (provenance not yet stamped)"));
|
|
179
|
-
|
|
180
|
-
(
|
|
181
|
-
|
|
216
|
+
if (hasCaptures) {
|
|
217
|
+
lines.push("Evidence: desktop + mobile captures below, taken by the platform audit harness after deploy (`" + auditDir + "`)." + verdictLine);
|
|
218
|
+
} else {
|
|
219
|
+
// Honest no-captures line (2026-09-17, room #14): never claim captures
|
|
220
|
+
// that do not exist. Terminal surfaces are named, not assumed.
|
|
221
|
+
const surfaceNote = project && project.environment_type === "terminal"
|
|
222
|
+
? " (terminal surface — QA judges the CLI, not pixels)"
|
|
223
|
+
: "";
|
|
224
|
+
lines.push("Evidence: no screenshots captured for this task" + surfaceNote + " — nothing to attach." + verdictLine);
|
|
225
|
+
}
|
|
226
|
+
if (visualUnavailable && hasCaptures) {
|
|
227
|
+
// The note's premise is the post-deploy capture — without captures it
|
|
228
|
+
// would fabricate evidence again (2026-09-17: the no-captures line above
|
|
229
|
+
// already covers the honest case).
|
|
182
230
|
lines.push("Note: the visual protocol is unavailable — these are the post-deploy capture plus QA's mechanical checks, not a QA visual inspection.");
|
|
183
231
|
}
|
|
184
232
|
process.stdout.write(lines.join("\n") + "\n");
|