muse-crew 0.12.0 → 0.13.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 CHANGED
@@ -474,18 +474,49 @@ Set a global configuration value.
474
474
 
475
475
  ### `setprovenance`
476
476
 
477
- Stamp publication provenance. Called by the crew Publish phase after rebuilding the artifact, and after installing/activating a crew release (npm path): in the release case Publish refreshes `crew_release` to the live release identity while preserving the existing `source_commit`.
477
+ Stamp publication provenance. Provenance is per-project (2026-09-18): each
478
+ project row carries its own publish base (`provenance_source_commit`,
479
+ `provenance_crew_release`, `provenance_published_at`, `provenance_task_id`);
480
+ a crew home with N artifact projects carries N independent bases. There are
481
+ two stamp call paths, and they are different operations on the same row:
482
+
483
+ - **Full stamp** — called by the parent's publish verification (`lib/verify-publish.js`)
484
+ after independently verifying the rebuilt artifact: `source_commit` is the
485
+ verified build commit, `crew_release` the active release at publish time.
486
+ - **Refresh** — called by the release installer (`lib/advance-publish-base.js`)
487
+ after installing/activating a crew release: `crew_release` is refreshed to
488
+ the live release identity while preserving the existing `source_commit`.
489
+ A base-pointer move is not a publish.
490
+
491
+ Both paths flow through the single writer in `lib/crew-api.js`; an unknown
492
+ `project_id` fails closed (exit 3), and omitting `project_id` is a usage
493
+ error (exit 2) — there is no silent global fallback.
478
494
 
479
495
  | Field | Type | Required | Notes |
480
496
  |-------|------|----------|-------|
497
+ | `project_id` | string | yes | The project whose publish base is stamped |
481
498
  | `source_commit` | 40-char hex | yes | Repo commit the artifact was built from |
482
- | `crew_release` | 40-char hex | yes | Active crew release (`$CREW_HOME/current`) at publish time |
483
- | `published_at` | ISO 8601 datetime (UTC) | yes | |
499
+ | `crew_release` | 40-char hex, or `pkg-<semver>` | yes | Active crew release (`$CREW_HOME/current`) at stamp time |
500
+ | `published_at` | ISO 8601 datetime (UTC) | yes | Defaults to now when omitted |
484
501
  | `task_id` | string | no | Publishing task, for traceability |
485
502
 
503
+ A one-time migration attributes a pre-per-project legacy `config.provenance.*`
504
+ triple to a project row only when its `source_commit` is an ancestor of
505
+ exactly one project's repo HEAD (verified with git); zero or ambiguous
506
+ matches are recorded as `deferred:<reason>` and never attributed. A triple
507
+ that matches exactly one project but violates the writer's shape rules
508
+ (e.g. a hand-written abbreviated sha, which git resolves for the ancestor
509
+ check but which fails the 40-char storage rule) is recorded as
510
+ `deferred:stamp-failed:<reason>` — the migration never throws, so a bad
511
+ triple can never brick the home. A `deferred:*` watermark is re-evaluated
512
+ when a project is created (the only event that changes the match set), so
513
+ a triple that predates the home's first project is attributed once a
514
+ descendant project exists. The legacy keys are inert dead storage after a
515
+ successful migration — new code never reads them.
516
+
486
517
  ### `getprovenance`
487
518
 
488
- Read the last publication provenance. Returns `{ "provenance": { "source_commit", "crew_release", "published_at", "task_id" } }`, or `{ "provenance": null }` when nothing has been published yet. `task_id` is the publishing task, for traceability — present when `setprovenance` was called with one, absent otherwise.
519
+ Read a project's publication provenance. Returns `{ "provenance": { "source_commit", "crew_release", "published_at", "task_id" } }`, or `{ "provenance": null }` when nothing has been published yet. A project that was never stamped reads `null` — another project's stamp is never visible here. `task_id` is the publishing task, for traceability — present when `setprovenance` was called with one, absent otherwise. `project_id` is required (exit 2 when omitted); an unknown `project_id` fails closed (exit 3).
489
520
 
490
521
  ---
491
522
 
@@ -223,7 +223,9 @@ For a task parked with `publish: verification-requested <commit>`:
223
223
  slug (the project's publish target).
224
224
  2. **Expected change.** The publish delta is `git diff <base> <commit>`
225
225
  in `repo_path`, where `<base>` is the previously-stamped provenance
226
- `source_commit` (read it via `get-provenance`; use the empty-tree sha
226
+ `source_commit` for this project (read it via
227
+ `get-provenance --json '{"project_id":"<id>"}'` — provenance is
228
+ per-project; without `project_id` the call exits 2; use the empty-tree sha
227
229
  `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when no provenance is
228
230
  stamped yet — a first publish). Never use `commit^1` as the base and
229
231
  never take the expected change from the builder's report: push-time
@@ -293,9 +295,15 @@ For a task parked with `publish: verification-requested <commit>`:
293
295
  (the crew-owned store). Do NOT use the artifact's `setprovenance`
294
296
  action — it writes a different, non-authoritative store that QA never
295
297
  reads, so the stamp would be invisible to every gate:
296
- `set-provenance --json '{"source_commit":"<commit>","crew_release":"<release>","task_id":"<task>"}'`
298
+ `set-provenance --json '{"project_id":"<id>","source_commit":"<commit>","crew_release":"<release>","task_id":"<task>"}'`
297
299
  (crew_release is the basename of the active release, e.g.
298
- `pkg-0.7.10`). Then read the stamp back with `get-provenance`
300
+ `pkg-0.7.10`). This is the full publish-verification stamp — it
301
+ records a verified publish. Do not confuse it with the
302
+ provenance *refresh* (re-pointing `crew_release` at the active
303
+ release after a crew upgrade without a new publish), which also goes
304
+ through `set-provenance` but keeps the existing `source_commit`.
305
+ Then read the stamp back with
306
+ `get-provenance --json '{"project_id":"<id>"}'`
299
307
  and confirm source_commit, crew_release, and task_id match exactly
300
308
  what was sent — a stamp that cannot be read back is not a stamp. Only
301
309
  then log the task note event
package/lib/AGENTS.md CHANGED
@@ -3,18 +3,18 @@
3
3
  Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, and the release system.
4
4
 
5
5
  - `build-registry.js` — deterministic extractor that generates `workflows/registry.json` (workflow step registry) from the workflow files' `meta` blocks at release time; invoked by `crew-release.sh` deploy
6
- - `crew-api.js` — the crew-owned task-service API (dependency inversion, 2026-09-11): a zero-dependency Node CLI implementing the API.md contract against `$CREW_HOME/crew-state.db` (schema in `schema.sql`). Workflows call it through their agents' shell; the dashboard delegates to it. All state-machine invariants live as CHECK constraints in the schema, never in client prose. Includes the `record-phase` composite (session + event in one transaction) and a one-time `migrate` import from a dashboard app.db.
6
+ - `crew-api.js` — the crew-owned task-service API (dependency inversion, 2026-09-11): a zero-dependency Node CLI implementing the API.md contract against `$CREW_HOME/crew-state.db` (schema in `schema.sql`). Workflows call it through their agents' shell; the dashboard delegates to it. All state-machine invariants live as CHECK constraints in the schema, never in client prose. Includes the `record-phase` composite (session + event in one transaction) and a one-time `migrate` import from a dashboard app.db. Active-release resolution is split in two (room #15): `resolveActiveReleaseName` (symlink-only — writers like the initial-provenance stamp record the active release without proving they are it) and `resolveActiveRelease` (symlink + self-path cross-check — verifiers like `scan-verification-pending` refuse to stamp claims when the running code isn't the active release's own). Provenance is per-project (2026-09-18, room #15 blocker 8): nullable `provenance_*` columns on the projects row, a single `stampProvenance()` writer, `set-provenance`/`get-provenance` require `project_id` (no silent global fallback), and a watermarked openDb backfill that attributes the legacy `config.provenance.*` triple to exactly-one ancestor match — never fabricated, otherwise deferred.
7
7
  - `schema.sql` — the crew-owned state schema: projects, tasks, poll_state, config, agent_sessions, events. Vocabularies enforced by CHECK constraints; `rejected` is a valid event type (the 2026-09-11 crash was a stored session whose event was rejected). Column names match the historical dashboard tables for a verbatim migration.
8
8
  - `crew-release.sh` — immutable release manager: deploy, rollback, prune
9
9
  - `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
10
- - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
10
+ - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure. post-deploy's `.worktrees/` guard is add-then-reset, never an all-negative pathspec: `git add -A -- ':!.worktrees/'` still exits 1 on git 2.43.0 against the crew's own gitignored non-empty `.worktrees/` (room #15 J3), killing post-deploy under `set -e` — a plain `add -A` never errors on ignored paths and the follow-up `git reset -q -- .worktrees/` keeps stray lock files out of main even on pre-gitignore-entry repos.
11
11
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
12
12
  - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
13
13
  - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
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, 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.
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 task's project-scoped 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.
@@ -34,4 +34,4 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
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
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
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]`.
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). Provenance is per-project (room #15 blocker 8): `--project-id` is required and every read/stamp is project-scoped. CLI: `--crew-home <path> --repo <path> --scaffold-sha <sha> --project-id <id> [--project-created]`.
@@ -39,7 +39,7 @@ const RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
39
39
 
40
40
  function usage() {
41
41
  console.error(
42
- "Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --scaffold-sha <sha> --project-created"
42
+ "Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --scaffold-sha <sha> --project-id <id> --project-created"
43
43
  );
44
44
  }
45
45
 
@@ -50,6 +50,7 @@ function parseArgv(argv) {
50
50
  if (a === "--crew-home") out.crewHome = argv[++i];
51
51
  else if (a === "--repo") out.repo = argv[++i];
52
52
  else if (a === "--scaffold-sha") out.scaffoldSha = argv[++i];
53
+ else if (a === "--project-id") out.projectId = argv[++i];
53
54
  else if (a === "--project-created") out.projectCreated = true;
54
55
  else {
55
56
  console.error("unknown argument: " + a);
@@ -94,10 +95,10 @@ function skip(reason) {
94
95
  }
95
96
 
96
97
  function main() {
97
- const { crewHome, repo, scaffoldSha, projectCreated } = parseArgv(
98
+ const { crewHome, repo, scaffoldSha, projectId, projectCreated } = parseArgv(
98
99
  process.argv.slice(2)
99
100
  );
100
- if (!crewHome || !repo || !scaffoldSha) {
101
+ if (!crewHome || !repo || !scaffoldSha || !projectId) {
101
102
  usage();
102
103
  process.exit(2);
103
104
  }
@@ -112,7 +113,7 @@ function main() {
112
113
 
113
114
  let provenance;
114
115
  try {
115
- provenance = crewApi(crewHome, "get-provenance").provenance;
116
+ provenance = crewApi(crewHome, "get-provenance", JSON.stringify({ project_id: projectId })).provenance;
116
117
  } catch (e) {
117
118
  console.error("get-provenance failed: " + (e.message || e));
118
119
  process.exit(1);
@@ -167,10 +168,17 @@ function main() {
167
168
  skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")");
168
169
  return;
169
170
  }
170
- const stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
171
- source_commit: scaffoldSha,
172
- crew_release: crewRelease,
173
- }));
171
+ let stamped;
172
+ try {
173
+ stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
174
+ project_id: projectId,
175
+ source_commit: scaffoldSha,
176
+ crew_release: crewRelease,
177
+ }));
178
+ } catch (e) {
179
+ console.error("set-provenance failed: " + (e.message || e));
180
+ process.exit(1);
181
+ }
174
182
  if (!stamped || stamped.ok !== true) {
175
183
  console.error("set-provenance did not return ok");
176
184
  process.exit(1);
@@ -215,19 +223,32 @@ function main() {
215
223
  return;
216
224
  }
217
225
 
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
- }));
226
+ let stamped;
227
+ try {
228
+ stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
229
+ project_id: projectId,
230
+ source_commit: scaffoldSha,
231
+ crew_release: crewRelease,
232
+ // A base-pointer move is not a publish: keep the existing published_at.
233
+ ...(provenance.published_at ? { published_at: provenance.published_at } : {}),
234
+ }));
235
+ } catch (e) {
236
+ console.error("set-provenance failed: " + (e.message || e));
237
+ process.exit(1);
238
+ }
224
239
  if (!stamped || stamped.ok !== true) {
225
240
  console.error("set-provenance did not return ok");
226
241
  process.exit(1);
227
242
  }
228
243
  // Exact read-back: the stamp must match what we intended, or it is fiction
229
244
  // (same discipline as lib/verify-publish.js).
230
- const recheck = crewApi(crewHome, "get-provenance").provenance;
245
+ let recheck;
246
+ try {
247
+ recheck = crewApi(crewHome, "get-provenance", JSON.stringify({ project_id: projectId })).provenance;
248
+ } catch (e) {
249
+ console.error("post-stamp get-provenance failed: " + (e.message || e));
250
+ process.exit(1);
251
+ }
231
252
  if (!recheck || recheck.source_commit !== scaffoldSha) {
232
253
  console.error("post-stamp read-back mismatch: provenance does not point at the scaffold commit");
233
254
  process.exit(1);
@@ -154,7 +154,8 @@ if (!verdict) {
154
154
  }
155
155
 
156
156
  // Provenance: this task's own newest `publish: verified` event first; the
157
- // global provenance record only when it belongs to this task.
157
+ // project-scoped provenance record only when it belongs to this task
158
+ // (provenance is per-project — room #15 blocker 8).
158
159
  let commit = null;
159
160
  const verifiedNote = notesNewest
160
161
  .find((e) => (e.message || "").includes("publish: verified"));
@@ -162,9 +163,9 @@ if (verifiedNote) {
162
163
  const m = /\b([0-9a-f]{40})\b/.exec(verifiedNote.message);
163
164
  if (m) commit = m[1];
164
165
  }
165
- if (!commit) {
166
+ if (!commit && project) {
166
167
  try {
167
- const prov = api("get-provenance", {}).provenance;
168
+ const prov = api("get-provenance", { project_id: project.id }).provenance;
168
169
  if (prov && prov.task_id === taskId && /^[0-9a-f]{40}$/.test(prov.source_commit || "")) {
169
170
  commit = prov.source_commit;
170
171
  }
package/lib/crew-api.js CHANGED
@@ -88,12 +88,148 @@ function resolveCrewHome(flagValue) {
88
88
 
89
89
  const SCHEMA_PATH = join(import.meta.dirname, "schema.sql");
90
90
 
91
+ // Provenance shape rules, shared by the writer and the backfill's
92
+ // pre-validation so they can never diverge (room #15, 2026-09-18).
93
+ const PROVENANCE_SOURCE_COMMIT_RE = /^[0-9a-f]{40}$/;
94
+ const PROVENANCE_CREW_RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
95
+
96
+ // One-time backfill of the legacy crew-home-global provenance triple
97
+ // (config.provenance.*) into the per-project provenance columns (room #15
98
+ // blocker 8, 2026-09-18). Attribution is never fabricated: the legacy base
99
+ // is stamped onto a project ONLY when it is an ancestor of exactly one
100
+ // project's repo HEAD (verified with git, never assumed). Zero or ambiguous
101
+ // matches are recorded as deferred:<reason> and the legacy keys are left
102
+ // untouched — new readers see null (empty-tree first publish) while old
103
+ // readers keep their global triple. Watermarked in config, so it runs at
104
+ // most once per crew home. Benign edge: a legacy key written by the very
105
+ // command whose open triggers the backfill is re-created after the DELETE
106
+ // and lingers in config — inert dead storage, never read by new code.
107
+ function backfillProjectProvenance(db) {
108
+ let wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
109
+ if (wm && wm.value === "none") {
110
+ // "none" means "no legacy triple at the last look". A triple that
111
+ // arrives later (hand-written via update-config — the key space is
112
+ // open) is new evidence, not a settled state: clear the watermark and
113
+ // evaluate it. Any other watermark (stamped:*, deferred:*) is terminal.
114
+ const legacy = db.prepare(
115
+ "SELECT key FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'"
116
+ ).all();
117
+ if (legacy.length > 0) {
118
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
119
+ wm = null;
120
+ } else {
121
+ return;
122
+ }
123
+ }
124
+ if (wm) return;
125
+ const setWatermark = (v) =>
126
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(v);
127
+ const rows = db.prepare(
128
+ "SELECT key, value FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at','provenance.task_id')"
129
+ ).all();
130
+ const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
131
+ const sc = byKey["provenance.source_commit"];
132
+ const cr = byKey["provenance.crew_release"];
133
+ const pa = byKey["provenance.published_at"];
134
+ if (!sc && !cr && !pa && !byKey["provenance.task_id"]) {
135
+ // No legacy provenance at all: nothing to attribute, ever.
136
+ setWatermark("none");
137
+ return;
138
+ }
139
+ if (!sc || !cr || !pa) {
140
+ // Incomplete triple (partial hand-write): do NOT watermark — the next
141
+ // open retries. Watermarking here would strand keys that arrive later.
142
+ return;
143
+ }
144
+ const matches = [];
145
+ for (const p of db.prepare("SELECT id, repo_path FROM projects").all()) {
146
+ try {
147
+ execFileSync("git", ["merge-base", "--is-ancestor", sc, "HEAD"],
148
+ { cwd: p.repo_path, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] });
149
+ matches.push(p.id);
150
+ } catch {
151
+ // Not an ancestor, or the repo is unreadable: not a match.
152
+ }
153
+ }
154
+ if (matches.length === 1) {
155
+ // Validate the legacy triple against the writer's shape rules BEFORE
156
+ // touching the database: a hand-written abbreviated sha passes
157
+ // `git merge-base --is-ancestor` (git resolves abbreviations) but
158
+ // violates the storage CHECK — and a throw here would escape openDb
159
+ // and hard-fail every CLI command against the home. The backfill's
160
+ // whole contract is best-effort attribution, so a bad shape is a
161
+ // deferred watermark, never an exception. Legacy keys are retained
162
+ // read-only for old readers, exactly like the other deferred cases.
163
+ const shapeProblem =
164
+ !PROVENANCE_SOURCE_COMMIT_RE.test(sc ?? "")
165
+ ? `source_commit not 40-char hex (${String(sc).slice(0, 24)})`
166
+ : !PROVENANCE_CREW_RELEASE_RE.test(cr ?? "")
167
+ ? `crew_release not 40-char hex or pkg-<semver> (${String(cr).slice(0, 24)})`
168
+ : null;
169
+ if (shapeProblem) {
170
+ setWatermark(`deferred:stamp-failed:${shapeProblem}`);
171
+ } else {
172
+ db.exec("BEGIN");
173
+ try {
174
+ db.prepare(
175
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
176
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ? WHERE id = ?`
177
+ ).run(sc, cr, pa, byKey["provenance.task_id"] ?? null, now(), matches[0]);
178
+ db.prepare("DELETE FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'").run();
179
+ setWatermark(`stamped:${matches[0]}`);
180
+ db.exec("COMMIT");
181
+ } catch (e) {
182
+ db.exec("ROLLBACK");
183
+ const reason = String((e && e.message) || e).replace(/[\r\n]+/g, " ").slice(0, 100);
184
+ setWatermark(`deferred:stamp-failed:${reason}`);
185
+ }
186
+ }
187
+ } else if (matches.length === 0) {
188
+ setWatermark(`deferred:no-ancestor-match:${String(sc).slice(0, 7)}`);
189
+ } else {
190
+ setWatermark(`deferred:ambiguous:${matches.length}`);
191
+ }
192
+ }
193
+
194
+ // Re-run a deferred backfill when the match set may have changed: a new
195
+ // project was just created, and a `deferred:no-ancestor-match` or
196
+ // `deferred:ambiguous` watermark from an earlier look can now resolve —
197
+ // e.g. a home whose legacy triple predates its first project. Only
198
+ // deferred:* watermarks are retried, and only while the legacy keys still
199
+ // exist (a successful stamp deleted them). Returns the stamped project id,
200
+ // or null. Never throws for a bad triple — the evaluation watermarks
201
+ // instead (see above); a throw here means the database itself is broken,
202
+ // and failing project creation is then the honest outcome.
203
+ function retryDeferredBackfill(db) {
204
+ const wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
205
+ if (!wm || !String(wm.value).startsWith("deferred:")) return null;
206
+ const legacy = db.prepare(
207
+ "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
208
+ ).all();
209
+ if (legacy.length === 0) return null;
210
+ // Lift the terminal watermark, re-evaluate, and let the evaluation write
211
+ // the new one. If the re-evaluation throws (it shouldn't — every path
212
+ // watermarks), restore the old watermark so the deferral isn't lost.
213
+ const old = wm.value;
214
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
215
+ try {
216
+ backfillProjectProvenance(db);
217
+ } catch (e) {
218
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(old);
219
+ throw e;
220
+ }
221
+ const wm2 = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
222
+ const m = wm2 && /^stamped:(.+)$/.exec(String(wm2.value));
223
+ return m ? m[1] : null;
224
+ }
225
+
91
226
  function openDb(crewHome) {
92
227
  const dbPath = join(crewHome, "crew-state.db");
93
228
  const fresh = !existsSync(dbPath);
94
229
  const db = new DatabaseSync(dbPath);
95
230
  db.exec("PRAGMA journal_mode = WAL;");
96
231
  db.exec("PRAGMA foreign_keys = ON;");
232
+ db.exec("PRAGMA busy_timeout = 5000;");
97
233
  if (fresh) {
98
234
  const schema = readFileSync(SCHEMA_PATH, "utf8");
99
235
  db.exec(schema);
@@ -139,6 +275,29 @@ function openDb(crewHome) {
139
275
  if (!/duplicate column name/i.test(e.message)) throw e;
140
276
  }
141
277
  }
278
+ // Migration: per-project provenance columns (room #15 blocker 8,
279
+ // 2026-09-18). Same idempotent PRAGMA-check pattern; ADD COLUMN without
280
+ // a default leaves existing rows NULL, which is "no provenance yet" by
281
+ // design — attribution is never fabricated, so there is no backfill step
282
+ // here beyond the watermarked legacy-triple attribution below.
283
+ const provMigration = [
284
+ ["provenance_source_commit", "TEXT CHECK (provenance_source_commit IS NULL OR length(provenance_source_commit) = 40)"],
285
+ ["provenance_crew_release", "TEXT"],
286
+ ["provenance_published_at", "TEXT"],
287
+ ["provenance_task_id", "TEXT"],
288
+ ];
289
+ const provColNames = new Set(db.prepare("PRAGMA table_info(projects)").all().map((c) => c.name));
290
+ for (const [colName, colType] of provMigration) {
291
+ if (!provColNames.has(colName)) {
292
+ try {
293
+ db.exec(`ALTER TABLE projects ADD COLUMN ${colName} ${colType}`);
294
+ } catch (e) {
295
+ if (!/duplicate column name/i.test(e.message)) throw e;
296
+ }
297
+ }
298
+ }
299
+ // Legacy global-provenance backfill (watermarked, runs at most once).
300
+ backfillProjectProvenance(db);
142
301
  }
143
302
  return db;
144
303
  }
@@ -276,7 +435,7 @@ function requireTask(db, id) {
276
435
 
277
436
  function requireProject(db, id) {
278
437
  const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
279
- if (!row) throw notFound("Project not found.");
438
+ if (!row) throw notFound(`Project not found: "${id}" — check list-projects, or create it with create-project.`);
280
439
  return row;
281
440
  }
282
441
 
@@ -1263,16 +1422,27 @@ commands["get-events"] = (db, args) => {
1263
1422
  // against this code's own realpath (the release it is actually running
1264
1423
  // from). Unresolvable or disputed => throw fail-closed BEFORE the claim
1265
1424
  // transaction: provenance is never stamped with an unknown release.
1266
- function resolveActiveRelease(crewHome) {
1425
+ //
1426
+ // Active-release resolution is split in two (room #15, 2026-09-18):
1427
+ // resolveActiveReleaseName (symlink only — for writers) below, and
1428
+ // resolveActiveRelease (symlink + self-path cross-check — for verifiers).
1429
+ function resolveActiveReleaseName(crewHome) {
1267
1430
  let linkTarget;
1268
1431
  try {
1269
1432
  linkTarget = readlinkSync(join(crewHome, "current"));
1270
1433
  } catch (e) {
1271
1434
  throw usageError(
1272
- `scan-verification-pending: cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}). Refusing to verify.`
1435
+ `cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}).`
1273
1436
  );
1274
1437
  }
1275
- const activeRelease = basename(resolve(crewHome, linkTarget));
1438
+ return basename(resolve(crewHome, linkTarget));
1439
+ }
1440
+ // The strict resolver for verifiers (scan-verification-pending): the symlink
1441
+ // answer PLUS the self-path cross-check — the running code must provably be
1442
+ // the active release's own crew-api.js before it stamps verification claims.
1443
+ // Stale cron bodies and mid-deploy swaps throw fail-closed here.
1444
+ function resolveActiveRelease(crewHome) {
1445
+ const activeRelease = resolveActiveReleaseName(crewHome);
1276
1446
  const selfPath = new URL(import.meta.url).pathname;
1277
1447
  const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
1278
1448
  if (!selfMatch) {
@@ -1575,39 +1745,49 @@ commands["record-phase"] = (db, args) => {
1575
1745
  // the JSON transport and over the 200-line budget. The artifact is built from
1576
1746
  // the repo checkout at registration time, so create-project stamps
1577
1747
  // source_commit = repo HEAD for artifact-deploy projects; the first publish
1578
- // then diffs only the task's own changes. The stamp never clobbers an
1579
- // existing one (provenance storage is crew-home-global, so a second artifact
1580
- // project keeps the first stamp — per-project provenance is a known gap this
1581
- // does not introduce). A skipped stamp is reported, never silent, and never
1582
- // fails project creation.
1583
- function initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName }) {
1748
+ // The initial-provenance stamp at create-project time: an artifact project
1749
+ // opens with its publish base set to the repo HEAD at registration, so the
1750
+ // first publish diffs only the task's own changes. Per-project (room #15
1751
+ // blocker 8): the project row was just inserted, so it is unstamped by
1752
+ // construction — there is no "already stamped" skip; a second artifact
1753
+ // project gets its own row, never the first project's base. A skipped stamp
1754
+ // is reported, never silent, and never fails project creation.
1755
+ function initialProvenancePlan({ deployType, headSha, releaseName }) {
1584
1756
  if (deployType !== "artifact") return { stamped: false, reason: "deploy_type is not artifact" };
1585
- if (provenanceExists) return { stamped: false, reason: "provenance already stamped" };
1586
1757
  if (!headSha) return { stamped: false, reason: "repo HEAD is unresolvable (unborn repo?)" };
1587
1758
  if (!releaseName) return { stamped: false, reason: "active crew release is unresolvable" };
1588
1759
  return { stamped: true, source_commit: headSha, crew_release: releaseName };
1589
1760
  }
1590
1761
 
1591
- function tryStampInitialProvenance(db, crewHome, repoPath, deployType) {
1592
- const keys = db.prepare(
1593
- "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
1594
- ).all().map((r) => r.key);
1595
- const provenanceExists = keys.length === 3;
1762
+ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType) {
1596
1763
  let headSha = null;
1597
1764
  try {
1598
1765
  headSha = execFileSync("git", ["rev-parse", "HEAD"],
1599
- { cwd: repoPath, encoding: "utf8", timeout: 10000 }).trim();
1766
+ { cwd: repoPath, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
1600
1767
  if (!/^[0-9a-f]{40}$/.test(headSha)) headSha = null;
1601
1768
  } catch { headSha = null; }
1602
1769
  let releaseName = null;
1603
- try { releaseName = resolveActiveRelease(crewHome); } catch { releaseName = null; }
1604
- const plan = initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName });
1770
+ // Symlink-only resolution: the stamp records the crew home's ACTIVE
1771
+ // release — it must not depend on which copy of this file is running.
1772
+ // The strict self-path cross-check lives in resolveActiveRelease, used by
1773
+ // scan-verification-pending; requiring it here silently skipped the stamp
1774
+ // for every setup path that invokes crew-api.js outside the release tree
1775
+ // (room #15: create-project through the npm package's own lib/).
1776
+ try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
1777
+ const plan = initialProvenancePlan({ deployType, headSha, releaseName });
1605
1778
  if (!plan.stamped) return plan;
1606
- const upsert = db.prepare(
1607
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1608
- upsert.run("provenance.source_commit", plan.source_commit);
1609
- upsert.run("provenance.crew_release", plan.crew_release);
1610
- upsert.run("provenance.published_at", now());
1779
+ try {
1780
+ stampProvenance(db, {
1781
+ project_id: projectId,
1782
+ source_commit: plan.source_commit,
1783
+ crew_release: plan.crew_release,
1784
+ });
1785
+ } catch (e) {
1786
+ // The stamp is best-effort: a writer rejection (e.g. a release name
1787
+ // outside the stampable shape) skips the stamp, never the project.
1788
+ // Reported via the reason, never silent.
1789
+ return { stamped: false, reason: `initial stamp rejected: ${e.message}` };
1790
+ }
1611
1791
  return plan;
1612
1792
  }
1613
1793
 
@@ -1675,7 +1855,17 @@ commands["create-project"] = (db, args, ctx) => {
1675
1855
  description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
1676
1856
  VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
1677
1857
  @description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
1678
- const initialProvenance = tryStampInitialProvenance(db, ctx.crewHome, repoPath, deployType);
1858
+ // A deferred backfill (zero/ambiguous matches at the last look) gets one
1859
+ // more evaluation now that the match set changed: a new project whose
1860
+ // repo descends from the legacy commit inherits the attribution instead
1861
+ // of stranding the triple in config forever. When the backfill stamps
1862
+ // this project, the initial stamp below is skipped — the legacy triple
1863
+ // is a real publish record and must not be overwritten by the
1864
+ // creation-time HEAD.
1865
+ const backfillStampedId = retryDeferredBackfill(db);
1866
+ const initialProvenance = backfillStampedId === id
1867
+ ? { stamped: true, reason: "legacy backfill attributed this project; initial stamp skipped" }
1868
+ : tryStampInitialProvenance(db, id, ctx.crewHome, repoPath, deployType);
1679
1869
  return { project: mapProject(row), initial_provenance: initialProvenance };
1680
1870
  };
1681
1871
 
@@ -1819,35 +2009,48 @@ commands["update-config"] = (db, args) => {
1819
2009
  return { ok: true };
1820
2010
  };
1821
2011
 
1822
- commands["set-provenance"] = (db, args) => {
1823
- if (!/^[0-9a-f]{40}$/.test(args.source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
1824
- if (!/^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/.test(args.crew_release ?? "")) {
2012
+ // The single provenance writer (room #15 blocker 8). Every stamp flows
2013
+ // through here: set-provenance and create-project's initial stamp are thin
2014
+ // shells over it. Per-project by design — a crew home with N artifact
2015
+ // projects carries N independent publish bases, and stamping one project
2016
+ // never touches another's row.
2017
+ function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
2018
+ const pid = String(project_id ?? "").trim();
2019
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2020
+ requireProject(db, pid);
2021
+ if (!PROVENANCE_SOURCE_COMMIT_RE.test(source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
2022
+ if (!PROVENANCE_CREW_RELEASE_RE.test(crew_release ?? "")) {
1825
2023
  throw usageError("crew_release must be 40-char hex or pkg-<semver>.");
1826
2024
  }
1827
2025
  // task_id is optional; when present it must be 1-80 chars (matches dashboard contract).
1828
- const taskId = args.task_id ?? null;
1829
- if (taskId !== null && (typeof taskId !== "string" || taskId.trim().length < 1 || taskId.length > 80)) {
2026
+ const tid = task_id ?? null;
2027
+ if (tid !== null && (typeof tid !== "string" || tid.trim().length < 1 || tid.length > 80)) {
1830
2028
  throw usageError("task_id must be 1-80 chars when provided.");
1831
2029
  }
1832
- const upsert = db.prepare(
1833
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1834
- upsert.run("provenance.source_commit", args.source_commit);
1835
- upsert.run("provenance.crew_release", args.crew_release);
1836
- upsert.run("provenance.published_at", args.published_at ?? now());
1837
- if (taskId !== null) upsert.run("provenance.task_id", taskId);
1838
- return { ok: true };
2030
+ db.prepare(
2031
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
2032
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ?
2033
+ WHERE id = ?`
2034
+ ).run(source_commit, crew_release, published_at ?? now(), tid, now(), pid);
2035
+ return { ok: true, project_id: pid };
2036
+ }
2037
+
2038
+ commands["set-provenance"] = (db, args) => {
2039
+ return stampProvenance(db, args ?? {});
1839
2040
  };
1840
2041
 
1841
- commands["get-provenance"] = (db) => {
1842
- const rows = db.prepare("SELECT key, value FROM config WHERE key LIKE 'provenance.%'").all();
1843
- const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
1844
- const sc = byKey["provenance.source_commit"];
1845
- const cr = byKey["provenance.crew_release"];
1846
- const pa = byKey["provenance.published_at"];
1847
- if (!sc || !cr || !pa) return { provenance: null };
2042
+ commands["get-provenance"] = (db, args) => {
2043
+ const pid = String((args ?? {}).project_id ?? "").trim();
2044
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2045
+ const row = requireProject(db, pid);
2046
+ if (!row.provenance_source_commit || !row.provenance_crew_release || !row.provenance_published_at) {
2047
+ return { provenance: null };
2048
+ }
1848
2049
  return { provenance: {
1849
- source_commit: sc, crew_release: cr, published_at: pa,
1850
- task_id: byKey["provenance.task_id"] ?? null,
2050
+ source_commit: row.provenance_source_commit,
2051
+ crew_release: row.provenance_crew_release,
2052
+ published_at: row.provenance_published_at,
2053
+ task_id: row.provenance_task_id ?? null,
1851
2054
  } };
1852
2055
  };
1853
2056
 
package/lib/schema.sql CHANGED
@@ -34,6 +34,18 @@ CREATE TABLE IF NOT EXISTS projects (
34
34
  -- deploy_type is NOT reused for this (it names the deployment target, not
35
35
  -- the UX surface a user experiences).
36
36
  environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal')),
37
+ -- Per-project provenance (2026-09-18, room #15 blocker 8): each project
38
+ -- carries its own publish base. Provenance was crew-home-global
39
+ -- (config.provenance.*), which broke any multi-project home: the first
40
+ -- artifact project's stamp was read as the second project's base, parking
41
+ -- the second task with "not an ancestor". Nullable by design: a project
42
+ -- with no provenance reads null and its first publish diffs from the
43
+ -- empty tree. Never fabricated — the one-time backfill in openDb stamps
44
+ -- only an exactly-one-ancestor match, otherwise records deferred.
45
+ provenance_source_commit TEXT CHECK (provenance_source_commit IS NULL OR length(provenance_source_commit) = 40),
46
+ provenance_crew_release TEXT,
47
+ provenance_published_at TEXT,
48
+ provenance_task_id TEXT,
37
49
  created_at TEXT NOT NULL,
38
50
  updated_at TEXT NOT NULL
39
51
  );
@@ -239,6 +239,35 @@ if git -C "$T5" log --name-only --format= | grep -q '^\.worktrees/'; then
239
239
  fi
240
240
  rm -rf "$T5"
241
241
 
242
+ # post-deploy survives the crew's own gitignored non-empty .worktrees/
243
+ # (2026-09-18, room #15 J3): the old `git add -A -- ':!.worktrees/'`
244
+ # exited 1 on git 2.43.0 whenever setup's `.gitignore` entry ignored a
245
+ # non-empty .worktrees/, and set -e killed post-deploy before "DEPLOYED:".
246
+ T6=/tmp/crew-git-postdeploy-gitignore-test
247
+ rm -rf "$T6"
248
+ mkdir -p "$T6"
249
+ git init -q -b main "$T6"
250
+ git -C "$T6" config user.email test@test.t
251
+ git -C "$T6" config user.name test
252
+ echo base > "$T6/f.txt"
253
+ echo ".worktrees/" > "$T6/.gitignore"
254
+ git -C "$T6" add -A
255
+ git -C "$T6" commit -qm base
256
+ export CREW_REPO="$T6"
257
+ export CREW_LIB="$REPO_DIR/lib"
258
+ # Non-empty ignored .worktrees/, exactly the shape of real integrate state
259
+ mkdir -p "$T6/.worktrees/pd2"
260
+ printf 'task_id=pd2\nholder=test\n' > "$T6/.worktrees/pd2/.merge-lock"
261
+ # A genuine user change the post-deploy commit should pick up
262
+ echo userchange > "$T6/user.txt"
263
+ out=$(bash "$LIFECYCLE" post-deploy "pd2") || fail "postdeploy-gitignore-test: post-deploy failed: $out"
264
+ echo "$out" | grep -q '^DEPLOYED: ' || fail "postdeploy-gitignore-test: no DEPLOYED: line: $out"
265
+ git -C "$T6" log --name-only --format= -1 | grep -q '^user\.txt$' || fail "postdeploy-gitignore-test: user change not committed"
266
+ if git -C "$T6" log --name-only --format= | grep -q '^\.worktrees/'; then
267
+ fail "postdeploy-gitignore-test: .worktrees/ content committed into main"
268
+ fi
269
+ rm -rf "$T6"
270
+
242
271
  rm -rf "$T2" "$T3" "$T4"
243
272
  export CREW_REPO="$T"
244
273
  rm -rf "$T"
@@ -23,7 +23,7 @@
23
23
  // node verify-publish.js --crew-home <path> --task-id <uuid>
24
24
  // --commit <sha> --base <sha> --repo-path <path> --slug <slug>
25
25
  // --crew-release <release> --inspection-id <uuid> --result-file <path>
26
- // [--build-agent-id <uuid>]
26
+ // --project-id <project-id> [--build-agent-id <uuid>]
27
27
  //
28
28
  // --base is the previously-stamped provenance source_commit (or the
29
29
  // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
@@ -66,6 +66,7 @@ const inspectionId = arg("--inspection-id");
66
66
  const resultFile = arg("--result-file");
67
67
  const crewRelease = arg("--crew-release");
68
68
  const buildAgentId = arg("--build-agent-id", false);
69
+ const projectId = arg("--project-id");
69
70
 
70
71
  if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
71
72
  if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
@@ -334,7 +335,7 @@ if (buildAgentId && resultText.includes(buildAgentId)) buildNote = `build-id-cor
334
335
  // --- 7. Stamp (only after the machine-checked comparison succeeded) ---------
335
336
  let stamp;
336
337
  try {
337
- stamp = api("set-provenance", { source_commit: commit, crew_release: crewRelease, task_id: taskId });
338
+ stamp = api("set-provenance", { project_id: projectId, source_commit: commit, crew_release: crewRelease, task_id: taskId });
338
339
  } catch (e) {
339
340
  terminal("stamp-failed", `set-provenance threw: ${e.message}`);
340
341
  }
@@ -342,7 +343,7 @@ if (!stamp || stamp.ok !== true) terminal("stamp-failed", "set-provenance did no
342
343
  // Exact read-back: the stamp must match what we intended, or it is fiction.
343
344
  let prov;
344
345
  try {
345
- prov = api("get-provenance", {});
346
+ prov = api("get-provenance", { project_id: projectId });
346
347
  } catch (e) {
347
348
  terminal("stamp-failed", `get-provenance threw: ${e.message}`);
348
349
  }
@@ -416,9 +416,15 @@ cmd_post_deploy() {
416
416
 
417
417
  # Commit any changes the artifact builder left behind.
418
418
  # The crew's own .worktrees/ dir (merge lock, registry) is crew-owned
419
- # state, never user content — exclude it so a stray lock file can never
420
- # be committed into main (J1 canary: .merge-lock landed in main via add -A).
421
- git add -A -- ':!.worktrees/'
419
+ # state, never user content — unstage it after the add so a stray lock
420
+ # file can never be committed into main (J1 canary: .merge-lock landed
421
+ # in main via add -A). Never use an all-negative pathspec here: on git
422
+ # 2.43.0 `git add -A -- ':!.worktrees/'` still exits 1 against the
423
+ # crew's own gitignored .worktrees/ (room #15, J3), and set -e then
424
+ # kills post-deploy before "DEPLOYED:". A plain add never errors on
425
+ # ignored paths; the reset covers pre-gitignore-entry repos too.
426
+ git add -A
427
+ git reset -q -- .worktrees/ 2>/dev/null || true
422
428
  if ! git diff --cached --quiet 2>/dev/null; then
423
429
  git commit -m "rebuild: $task_id"
424
430
  echo "COMMITTED: artifact builder changes"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -40,11 +40,13 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
40
40
  4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects, reads back, and certifies; you are only the ferry between the deterministic steps (scan → sensor → verifier).
41
41
  - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-verification-pending`
42
42
  This atomically claims each verification-pending task (1-hour lease, so a second tick cannot double-verify) and reconciles verified-but-still-parked tasks to `in_progress`. It returns `{ to_verify: [...], reconciled: [...] }`. Log both lists. If the scan exits 2 (e.g. the active release cannot be resolved), log the error loudly and continue — do NOT work around it.
43
- - **Read-back (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish), then run:
43
+ - **Read-back (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish):
44
+ `node {crewHome}/lib/crew-api.js --crew-home {crewHome} get-provenance --json '{"project_id": "<project_id>"}'`
45
+ (use the entry's `project_id` verbatim), then run:
44
46
  `node {crewHome}/lib/readback-disk.js --repo-path "<repo_path>" --commit <commit> --base <base> --slug "<deploy_slug>" --task-id <task_id> > /tmp/readback-<task_id>.txt 2> /tmp/readback-<task_id>.err`
45
47
  Use the entry's `repo_path` and `deploy_slug` verbatim. If the sensor exits 0, the result file holds the findings block — hand it to the verify step below. If it exits non-zero, do NOT save or use stdout: log `publish: verification-procedural-error <commit> <first line of the .err file>` and leave the task parked — the next tick retries. A sensor failure is procedural (the read could not be performed), never a content verdict. Do NOT judge content yourself, and do NOT stamp provenance.
46
- - **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
47
- Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
48
+ - **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --project-id <project_id> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
49
+ Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with and the entry's `project_id` verbatim. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
48
50
  - Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The verify script's machine-checked comparison is the only certification.
49
51
 
50
52
  5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
@@ -83,13 +83,18 @@ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
83
83
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
84
84
  const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
85
85
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
86
- // The six basenames the pin step must materialize — asserted mechanically
86
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
87
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
88
+ // The seven basenames the pin step must materialize — asserted mechanically
87
89
  // by workflow code from the verbatim listing, never from agent prose.
88
90
  // COMPUTE_DIFF is the deterministic publish-diff computer (room #14,
89
91
  // 2026-09-17): the diff is computed by this script, never ferried as an
90
92
  // agent JSON string. Pinned like the other publish-critical modules so a
91
93
  // mid-run release swap cannot change it under the workflow.
92
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF].map(function (p) { return p.split("/").pop(); });
94
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
95
+ // crew-api.js statically imports it, so the pin must carry it — a pin
96
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
97
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
93
98
 
94
99
  // Project config — passed by dispatcher, falls back to dashboard defaults
95
100
  const projectConfig = inputs.project_config || {};
@@ -273,7 +278,7 @@ function attemptKey(base, reworkCount) {
273
278
  function pinLifecycle(key) {
274
279
  return agent(
275
280
  "Snapshot lifecycle scripts for version pinning.\n" +
276
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
281
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
277
282
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
278
283
  { key: key, label: "Pinning lifecycle scripts",
279
284
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -1548,7 +1553,7 @@ while (i < STEPS.length) {
1548
1553
  // first publish (no provenance stamped yet).
1549
1554
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1550
1555
  var provResult = await agent(
1551
- crewCmd("get-provenance", {}) + "\n" +
1556
+ crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
1552
1557
  "Return JSON { \"provenance\": <the CLI's provenance object, or null when nothing is stamped> } and nothing else. Do not interpret it.",
1553
1558
  { key: attemptKey("publish-provenance-base-" + taskId, totalReworkCount), label: "Reading stamped publish base",
1554
1559
  schema: { type: "object", properties: { provenance: { type: ["object", "null"] } }, required: ["provenance"] } }
@@ -2399,7 +2404,7 @@ while (i < STEPS.length) {
2399
2404
  if (PUBLISH_TYPE === "artifact") {
2400
2405
  instructions = "PROVENANCE CHECK (this project publishes to a dashboard artifact).\n" +
2401
2406
  "Provenance is crew-owned state: read it from the Crew API, never from the artifact's own getprovenance action (a different, non-authoritative store).\n" +
2402
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2407
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2403
2408
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
2404
2409
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
2405
2410
  "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, report 'provenance mismatch: crew_release [value from get-provenance] not found in release registry', then end your report with exactly this line: VERDICT: FAIL.\n" +
@@ -2944,11 +2949,11 @@ while (i < STEPS.length) {
2944
2949
  // dashboard QA source check).
2945
2950
  try {
2946
2951
  var provRefresh = await agent(
2947
- "Run in shell and read the stdout JSON:\n" + crewCmd("get-provenance", {}) + "\n" +
2952
+ "Run in shell and read the stdout JSON:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2948
2953
  "If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
2949
2954
  "Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
2950
2955
  "run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
2951
- "Then run in shell: node " + CREW_API + " --crew-home " + crewHome + " set-provenance --json '{\"source_commit\":\"<the existing provenance.source_commit value>\",\"crew_release\":\"<REL>\",\"published_at\":\"<TS>\",\"task_id\":\"" + taskId + "\"}' " +
2956
+ "Then run in shell: node " + CREW_API + " --crew-home " + crewHome + " set-provenance --json '{\"project_id\":\"" + LAUNCH_PROJECT_ID + "\",\"source_commit\":\"<the existing provenance.source_commit value>\",\"crew_release\":\"<REL>\",\"published_at\":\"<TS>\",\"task_id\":\"" + taskId + "\"}' " +
2952
2957
  "(substitute the real values; the JSON must be single-quote-wrapped for the shell). " +
2953
2958
  "Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
2954
2959
  { key: attemptKey("publish-provenance-refresh-" + taskId, totalReworkCount), label: "Refreshing dashboard provenance after crew release",
@@ -140,13 +140,18 @@ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
140
140
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
141
141
  const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
142
142
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
143
- // The six basenames the pin step must materialize — asserted mechanically
143
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
144
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
145
+ // The seven basenames the pin step must materialize — asserted mechanically
144
146
  // by workflow code from the verbatim listing, never from agent prose.
145
147
  // COMPUTE_DIFF is the deterministic publish-diff computer (room #14,
146
148
  // 2026-09-17): the diff is computed by this script, never ferried as an
147
149
  // agent JSON string. Pinned like the other publish-critical modules so a
148
150
  // mid-run release swap cannot change it under the workflow.
149
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF].map(function (p) { return p.split("/").pop(); });
151
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
152
+ // crew-api.js statically imports it, so the pin must carry it — a pin
153
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
154
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
150
155
 
151
156
  // Project config — passed by dispatcher, falls back to dashboard defaults
152
157
  const projectConfig = inputs.project_config || {};
@@ -325,7 +330,7 @@ function attemptKey(base, reworkCount) {
325
330
  function pinLifecycle(key) {
326
331
  return agent(
327
332
  "Snapshot lifecycle scripts for version pinning.\n" +
328
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
333
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
329
334
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
330
335
  { key: key, label: "Pinning lifecycle scripts",
331
336
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -1435,7 +1440,7 @@ while (i < STEPS.length) {
1435
1440
  // first publish (no provenance stamped yet).
1436
1441
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1437
1442
  var provResult = await agent(
1438
- crewCmd("get-provenance", {}) + "\n" +
1443
+ crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
1439
1444
  "Return JSON { \"provenance\": <the CLI's provenance object, or null when nothing is stamped> } and nothing else. Do not interpret it.",
1440
1445
  { key: attemptKey("publish-provenance-base-" + taskId, reworkCount), label: "Reading stamped publish base",
1441
1446
  schema: { type: "object", properties: { provenance: { type: ["object", "null"] } }, required: ["provenance"] } }
@@ -2521,11 +2526,11 @@ while (i < STEPS.length) {
2521
2526
  // dashboard QA source check).
2522
2527
  try {
2523
2528
  var provRefresh = await agent(
2524
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2529
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2525
2530
  "If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
2526
2531
  "Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
2527
2532
  "run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
2528
- "Then run in shell:\n" + crewCmd("set-provenance", { source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2533
+ "Then run in shell:\n" + crewCmd("set-provenance", { project_id: LAUNCH_PROJECT_ID, source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2529
2534
  "(substitute the real existing source_commit, REL, and TS for the placeholders). " +
2530
2535
  "Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
2531
2536
  { key: attemptKey("publish-provenance-refresh-" + taskId, reworkCount), label: "Refreshing dashboard provenance after crew release",
@@ -350,6 +350,7 @@ log("Scaffold: " + scaffoldCreated + " created, " + scaffoldSkipped + " skipped"
350
350
  // projects later via crew-api.js create-project.
351
351
  phase("project");
352
352
  var projectResult;
353
+ var initialProvenanceWarning = null;
353
354
  if (!dashboardSlug) {
354
355
  log("Project: skipped (CLI-only mode — no dashboard; create projects via crew-api.js create-project)");
355
356
  projectResult = { action: "skipped", project_id: null };
@@ -380,7 +381,8 @@ try {
380
381
  " Return { action: \"repaired\", project_id: \"" + dashboardSlug + "\" }.\n" +
381
382
  "3. If not found (error), create it:\n" +
382
383
  " node " + crewHome + "/current/lib/crew-api.js --crew-home " + crewHome + " create-project --json '{\"id\": \"" + dashboardSlug + "\", \"display_name\": \"" + safeDashboardName + "\", \"repo_path\": \"" + safeDashboardRepo + "\", \"deploy_type\": \"artifact\", \"deploy_slug\": \"" + dashboardSlug + "\", \"environment_type\": \"artifact\", \"description\": \"The dashboard task service — the crew\\u0027s first project\"}'\n" +
383
- " Return { action: \"created\", project_id: \"" + dashboardSlug + "\" }.\n\n" +
384
+ " The create-project response carries initial_provenance { stamped, reason?, source_commit?, crew_release? } — pass it through verbatim.\n" +
385
+ " Return { action: \"created\", project_id: \"" + dashboardSlug + "\", initial_provenance: <the verbatim initial_provenance object> }.\n\n" +
384
386
  "4. Seed the update watcher's trusted dashboard base (enrollment) — the\n" +
385
387
  " commit this installation starts from. Only ever seeds when no base\n" +
386
388
  " exists; an established base is never moved by a re-init:\n" +
@@ -394,7 +396,7 @@ try {
394
396
  " dashboard upgrade with oldSha unknown and park it with enrollment\n" +
395
397
  " guidance, fail-closed.\n\n" +
396
398
  "The repo_path must be the dashboard git repository — never the crew home.\n" +
397
- "Return JSON with action (\"exists\", \"repaired\", or \"created\") and project_id (string).",
399
+ "Return JSON with action (\"exists\", \"repaired\", or \"created\"), project_id (string), and — only when action is \"created\" — the create-project response's initial_provenance object verbatim.",
398
400
  {
399
401
  key: "project-1",
400
402
  label: "Register dashboard project",
@@ -402,7 +404,8 @@ try {
402
404
  type: "object",
403
405
  properties: {
404
406
  action: { type: "string", enum: ["exists", "repaired", "created"] },
405
- project_id: { type: "string" }
407
+ project_id: { type: "string" },
408
+ initial_provenance: { type: "object" }
406
409
  },
407
410
  required: ["action", "project_id"]
408
411
  }
@@ -412,6 +415,18 @@ try {
412
415
  return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Project registration failed", message: String(e.message || e) } };
413
416
  }
414
417
  log("Project: " + dashboardSlug + " " + projectResult.action + " (repo_path=" + gateFacts.dashboardRepoExpanded + ")");
418
+ if (projectResult && projectResult.action === "created" &&
419
+ projectResult.initial_provenance && !projectResult.initial_provenance.stamped) {
420
+ // Room #15 (reliability): create-project's initial stamp is best-effort,
421
+ // but "reported" used to mean "returned into the void" — zero production
422
+ // code read it, so a skipped stamp (the J1/J2 shape) stayed invisible.
423
+ // Surface it here; the publish-base advance below re-stamps right after,
424
+ // and fails loudly (exit 1) if the re-stamp itself cannot stamp.
425
+ initialProvenanceWarning = "Project created but its initial provenance stamp was skipped (" +
426
+ (projectResult.initial_provenance.reason || "unknown reason") +
427
+ ") — the publish-base advance re-stamps it next; if that also fails, the first task's publish diffs from the empty tree.";
428
+ log("WARNING: " + initialProvenanceWarning);
429
+ }
415
430
  } // end dashboard-mode project registration
416
431
 
417
432
  // ── Project repo setup (2026-09-18) ─────────────────────────────────
@@ -512,7 +527,7 @@ if (dashboardRepoPath && projectResult && projectResult.action === "created" &&
512
527
  publishBaseResult = await agent(
513
528
  "Run the deterministic publish-base advance script. Do not decide anything — just run the command and return its JSON output.\n\n" +
514
529
  "Command:\n" +
515
- "node " + crewHome + "/current/lib/advance-publish-base.js --crew-home \"" + crewHome + "\" --repo \"" + gateFacts.dashboardRepoExpanded + "\" --scaffold-sha " + setupRepoResult.commit.sha + " --project-created\n\n" +
530
+ "node " + crewHome + "/current/lib/advance-publish-base.js --crew-home \"" + crewHome + "\" --repo \"" + gateFacts.dashboardRepoExpanded + "\" --scaffold-sha " + setupRepoResult.commit.sha + " --project-id " + projectResult.project_id + " --project-created\n\n" +
516
531
  "The script prints one JSON object: { ok, action, reason?, from?, to? }.\n" +
517
532
  "Return that JSON verbatim.",
518
533
  {
@@ -801,6 +816,7 @@ return {
801
816
  scaffold: { created: scaffoldCreated, skipped: scaffoldSkipped },
802
817
  project: projectResult.action,
803
818
  repoSetupWarning: setupRepoWarning,
819
+ initialProvenanceWarning: initialProvenanceWarning,
804
820
  crons: cronsResult.summary.crons,
805
821
  registry: cronsResult.summary.registry,
806
822
  autoUpdate: {
@@ -91,13 +91,18 @@ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
91
91
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
92
92
  const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
93
93
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
94
- // The six basenames the pin step must materialize — asserted mechanically
94
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
95
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
96
+ // The seven basenames the pin step must materialize — asserted mechanically
95
97
  // by workflow code from the verbatim listing, never from agent prose.
96
98
  // COMPUTE_DIFF is the deterministic publish-diff computer (room #14,
97
99
  // 2026-09-17): the diff is computed by this script, never ferried as an
98
100
  // agent JSON string. Pinned like the other publish-critical modules so a
99
101
  // mid-run release swap cannot change it under the workflow.
100
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF].map(function (p) { return p.split("/").pop(); });
102
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
103
+ // crew-api.js statically imports it, so the pin must carry it — a pin
104
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
105
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
101
106
 
102
107
  // Project config — passed by dispatcher, falls back to dashboard defaults
103
108
  const projectConfig = inputs.project_config || {};
@@ -281,7 +286,7 @@ function attemptKey(base, reworkCount) {
281
286
  function pinLifecycle(key) {
282
287
  return agent(
283
288
  "Snapshot lifecycle scripts for version pinning.\n" +
284
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
289
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
285
290
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
286
291
  { key: key, label: "Pinning lifecycle scripts",
287
292
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -1429,7 +1434,7 @@ while (i < STEPS.length) {
1429
1434
  // first publish (no provenance stamped yet).
1430
1435
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1431
1436
  var provResult = await agent(
1432
- crewCmd("get-provenance", {}) + "\n" +
1437
+ crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
1433
1438
  "Return JSON { \"provenance\": <the CLI's provenance object, or null when nothing is stamped> } and nothing else. Do not interpret it.",
1434
1439
  { key: attemptKey("publish-provenance-base-" + taskId, totalReworkCount), label: "Reading stamped publish base",
1435
1440
  schema: { type: "object", properties: { provenance: { type: ["object", "null"] } }, required: ["provenance"] } }
@@ -2226,7 +2231,7 @@ while (i < STEPS.length) {
2226
2231
  "Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
2227
2232
  "Use the returned tasks, sessions, and events to check the task's data-level effects.\n" +
2228
2233
  "DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale for a public-affecting change, report 'public docs missing/stale for [the change]', then end your report with exactly this line: VERDICT: FAIL. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n\n" +
2229
- "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2234
+ "PROVENANCE CHECK: Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2230
2235
  "If provenance is null, report 'provenance missing — publish did not stamp source/crew release', then end your report with exactly this line: VERDICT: FAIL.\n" +
2231
2236
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD — call this LIVE_HEAD.\n" +
2232
2237
  "Run: test -d " + crewHome + "/releases/<provenance.crew_release> (substitute the real stamped hash; do not run the literal placeholder). If the directory does not exist, FAIL: { \"passed\": false, \"summary\": \"provenance mismatch: crew_release [value from get-provenance] not found in release registry\" }.\n" +
@@ -2703,11 +2708,11 @@ while (i < STEPS.length) {
2703
2708
  // dashboard QA source check).
2704
2709
  try {
2705
2710
  var provRefresh = await agent(
2706
- "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
2711
+ "Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", { project_id: LAUNCH_PROJECT_ID }) + "\n" +
2707
2712
  "If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
2708
2713
  "Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
2709
2714
  "run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
2710
- "Then run in shell:\n" + crewCmd("set-provenance", { source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2715
+ "Then run in shell:\n" + crewCmd("set-provenance", { project_id: LAUNCH_PROJECT_ID, source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
2711
2716
  "(substitute the real existing source_commit, REL, and TS for the placeholders). " +
2712
2717
  "Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
2713
2718
  { key: attemptKey("publish-provenance-refresh-" + taskId, totalReworkCount), label: "Refreshing dashboard provenance after crew release",
@@ -157,9 +157,14 @@ const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
157
157
  const CREW_API_PINNED = RUN_LIB + "/crew-api.js";
158
158
  const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
159
159
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
160
- // The five basenames the pin step must materialize — asserted mechanically
160
+ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
161
+ const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
162
+ // The six basenames the pin step must materialize — asserted mechanically
161
163
  // by workflow code from the verbatim listing, never from agent prose.
162
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED].map(function (p) { return p.split("/").pop(); });
164
+ // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
165
+ // crew-api.js statically imports it, so the pin must carry it — a pin
166
+ // without it kills every claim with ERR_MODULE_NOT_FOUND.
167
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
163
168
 
164
169
  // Project config — passed by dispatcher, falls back to dashboard defaults
165
170
  const projectConfig = inputs.project_config || {};
@@ -190,7 +195,7 @@ if (!taskId) {
190
195
  function pinLifecycle(key) {
191
196
  return agent(
192
197
  "Snapshot lifecycle scripts for version pinning.\n" +
193
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
198
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
194
199
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
195
200
  { key: key, label: "Pinning lifecycle scripts",
196
201
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }