muse-crew 0.14.5 → 0.14.7

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.
Files changed (41) hide show
  1. package/API.md +106 -12
  2. package/docs/decisions/AGENTS.md +5 -0
  3. package/docs/decisions/publish-path.md +247 -9
  4. package/docs/decisions/qa-reproduce.md +30 -0
  5. package/docs/guide.md +41 -5
  6. package/docs/publish-unknown-recovery.md +28 -120
  7. package/docs/publish-verification.md +148 -501
  8. package/lib/AGENTS.md +9 -13
  9. package/lib/advance-publish-base.js +3 -3
  10. package/lib/compose-evidence-caption.js +1 -2
  11. package/lib/compute-publish-diff.js +82 -5
  12. package/lib/crew-api.js +1134 -1027
  13. package/lib/merge-lock.sh +153 -41
  14. package/lib/publish-note-vocabulary.js +135 -42
  15. package/lib/qa-db.js +132 -0
  16. package/lib/schema.sql +69 -0
  17. package/lib/serve-artifact.js +46 -2
  18. package/lib/test-detached-integrate.sh +122 -0
  19. package/lib/test-merge-lock.sh +30 -1
  20. package/lib/worktree-lifecycle.sh +284 -34
  21. package/package.json +1 -1
  22. package/seed/AGENTS.md +1 -0
  23. package/seed/cron-body-ack-scan.md +44 -0
  24. package/seed/cron-body-template.md +55 -89
  25. package/seed/crons.json +12 -0
  26. package/workflows/AGENTS.md +1 -1
  27. package/workflows/bugfix.js +489 -224
  28. package/workflows/chore.js +306 -226
  29. package/workflows/crew-dispatch.js +57 -4
  30. package/workflows/crew-init.js +23 -0
  31. package/workflows/crew-uninstall.js +7 -4
  32. package/workflows/docs.js +24 -2
  33. package/workflows/standard.js +494 -251
  34. package/workflows/upgrade.js +13 -1
  35. package/lib/build-readback-request.js +0 -140
  36. package/lib/check-intent-freshness.js +0 -101
  37. package/lib/classify-publish-absence.js +0 -462
  38. package/lib/publish-content.js +0 -154
  39. package/lib/readback-disk.js +0 -195
  40. package/lib/retry-publish.js +0 -417
  41. package/lib/verify-publish.js +0 -416
package/lib/AGENTS.md CHANGED
@@ -9,12 +9,12 @@ Shipped library: ESM JavaScript CLIs and import-safe modules, shell scripts for
9
9
  - **Release entry gate.** `_validate_lib_entries` in `crew-release.sh` (wired into `cmd_deploy` after `_validate_workflows`) executes every shipped JS entry for real through a `$CREW_HOME/current`-shaped symlink: shebang'd JS as `node <file> --help`, shebang-less JS as bare `node <file>`; `lib/*.sh` (minus `test-*.sh`) via `bash -n` and `lib/*.py` via `python3 -m py_compile` are parse-checked only (real `.sh` execution risks side effects), with bytecode kept out of the staging dir. `node --check` is banned — it can check under a different parse goal than the real loader uses (blocker 21), and V8's preparser skips function bodies; only real execution uses the loader's goal. Per-entry `ENTRY-GATE pass/FAIL` lines (FAIL reasons fold the first 10 stderr lines), one row per entry in `<staging>/entry-gate.json` (`test-*.sh` exclusions get `skipped` rows), exit 30 on any entry failure, deploy fails closed naming the failing entries and preserving the evidence in `$CREW_HOME`. Contract page: `docs/release-integrity.md`; behavioral pins: `tests/entry-gate.test.js`.
10
10
 
11
11
  - `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
12
- - `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.
12
+ - `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; 2026-09-21 optionally carries a structured Review verdict payload — validated at the API boundary, written to the `verdicts` table in the same transaction, session-keyed first-write-wins idempotency — plus the `get-verdicts` read surface) 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-ack-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.
13
13
  - `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.
14
14
  - `crew-release.sh` — immutable release manager: deploy, rollback, prune
15
- - `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
16
- - `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 (the integration target is the repo's current checkout — a branch name, or HEAD when detached — resolved by `integration_target()`; nothing in the lifecycle ever checks out a branch. `integrate` merges into the target, reconciles `origin/<target>` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — then pushes inline via `do_push` (no agent round-trip; 2026-09-19 REVIEW collapsed the old STEP-2 agent run, which added only a lock refresh). `do_push`: PUSHED (the refspec is always explicit — a branch pushes as `<branch>`, a detached HEAD as `HEAD:<destination>` where the destination is the remote's default branch from `resolve_push_destination()`; 2026-09-19 detached-HEAD audit REDO) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); an unresolvable detached destination fails closed (never a silent skip, never a refusal of a knowable push); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). PUSH_SKIPPED survives only for the nothing-merged R5 path (no merge record + no lock held). Merged-but-unpushed retry recovery: a record whose merge commit IS the live HEAD takes the lock and pushes instead of reporting MERGED_EMPTY. `CREW_STAGED_BASE` guard (ferried from get-provenance): the line must descend from the staged publish base — STAGED_BASE_MISMATCH fails closed before the merge (room #21 J3 shape). `verify-merge` always re-resolves the target from the current checkout and checks the recorded commit against the LIVE tip — never the record's word for the tip (2026-09-19 REVIEW: the old read-back compared the record to itself for target=HEAD). A detached HEAD skips the inline reconcile loudly (NO_REMOTE_RECONCILE) — the inline reconcile needs a static branch target, so on detached the destination is resolved explicitly in do_push, which reconciles lazily on push failure (2026-09-19 detached-HEAD audit REDO; the old "local by design" rationale is rejected); `push-target` is the thin R5 wrapper over `do_push`: refreshes the lock, distinguishes nothing-merged (no record + no lock → loud skip) from lock-lost (fail closed). (`.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 a dirty integration target; 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 the integration target — 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 the integration target even on pre-gitignore-entry repos.
17
- - `test-detached-integrate.sh` — behavioral regression tests for the detached-HEAD Integrate fix (room #21 J3, 2026-09-19; extended 2026-09-19 REVIEW): fixture A stages a detached HEAD (reviewed SHA + crew-init commit) and proves integrate leaves HEAD detached, keeps the reviewed SHA and crew-init as ancestors, lands the task merge, never moves main, records `integration_target=HEAD`, verify-merge VERIFIEDs, and push-target reports NO_REMOTE_PUSH (fixture A has no origin); fixture B proves the branch path still merges, reconciles, and pushes inline to a local origin; fixture C proves verify-merge checks the record against the LIVE tip (a detached line reset past the merge does NOT verify; the branch-gone path verifies only when the recorded commit is an ancestor of the live tip); fixture D proves the no-remote branch path fails soft with NO_REMOTE_PUSH (exit 0); fixture E proves the CREW_STAGED_BASE guard (non-ancestor base → STAGED_BASE_MISMATCH before any merge; true base → STAGED_BASE_OK); fixture F proves merged-but-unpushed retry recovery (re-running integrate after a crashed push takes the lock and pushes instead of parking on MERGED_EMPTY); fixture H proves the detached-HEAD audit REDO contract (2026-09-19) on a detached checkout with a local origin: push-destination fails closed when origin/HEAD is unset (genuinely unknowable destination), otherwise resolves the remote default branch; integrate pushes inline as `HEAD:main` (`PUSHED: origin/main (refspec HEAD:main)`), the remote ref advances to the merge commit, local main stays at the reviewed base, HEAD stays detached, verify-merge VERIFIEDs, and push-target re-pushes idempotently. Scratch dirs under /tmp only; CREW_HOME lives outside the scratch repos (as in real rooms) so lock files never trip the integrate preflight.
15
+ - `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. Stale-lease breaks serialize on a sidecar `$LOCK_FILE.flock` with an in-critical-section lease re-read (R-B1, 2026-09-21); refresh rewrites via temp-file + atomic rename so readers never see a torn file, and takes the same sidecar flock with an identity-only re-check inside the critical section — a reclaim always changes `task_id`, so identity alone closes the clobber (no expiry check on refresh: a long build that outran the lease legitimately revives its lock); release takes the same flock with the identity re-check inside (review pass 2, 2026-09-21) so a release can never `rm` a reclaimer's fresh lock.
16
+ - `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 (the integration target is the repo's current checkout — a branch name, or HEAD when detached — resolved by `integration_target()`; nothing in the lifecycle ever checks out a branch. `integrate` merges into the target, reconciles `origin/<target>` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — then pushes inline via `do_push` (no agent round-trip; 2026-09-19 REVIEW collapsed the old STEP-2 agent run, which added only a lock refresh). `do_push`: PUSHED (the refspec is always explicit — a branch pushes as `<branch>`, a detached HEAD as `HEAD:<destination>` where the destination is the remote's default branch from `resolve_push_destination()`; 2026-09-19 detached-HEAD audit REDO) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); an unresolvable detached destination fails closed (never a silent skip, never a refusal of a knowable push); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). PUSH_SKIPPED survives only for the nothing-merged R5 path (no merge record + no lock held). Merged-but-unpushed retry recovery (blocker 35, 2026-09-21): identity-based, not ancestry-based — `current_branch_merge()` finds the newest merge on live HEAD's first-parent chain whose branch-side parent (^2) is the branch's CURRENT tip (the recorded merge, a do_push reconcile merge above it, or the manual R5 merge); that merge takes the lock and pushes instead of reporting MERGED_EMPTY. A record with no such merge means the branch moved since the merge — STALE_MERGE fails closed (never pushed, never MERGED_EMPTY). Branch gone (merge-lease reclaim): the recorded merge on the line is still the deliverable (no rework could have moved it). `push-target` proves the same identity before the ERROR-after-MERGED retry push. `CREW_STAGED_BASE` guard (ferried from get-provenance): the line must descend from the staged publish base — STAGED_BASE_MISMATCH fails closed before the merge (room #21 J3 shape). `verify-merge` always re-resolves the target from the current checkout and checks the recorded commit against the LIVE tip — never the record's word for the tip (2026-09-19 REVIEW: the old read-back compared the record to itself for target=HEAD). A detached HEAD skips the inline reconcile loudly (NO_REMOTE_RECONCILE) — the inline reconcile needs a static branch target, so on detached the destination is resolved explicitly in do_push, which reconciles lazily on push failure (2026-09-19 detached-HEAD audit REDO; the old "local by design" rationale is rejected); `push-target` is the thin R5 wrapper over `do_push`: refreshes the lock, distinguishes nothing-merged (no record + no lock → loud skip) from lock-lost (fail closed). (`.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 a dirty integration target; 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 the integration target — 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 the integration target even on pre-gitignore-entry repos.
17
+ - `test-detached-integrate.sh` — behavioral regression tests for the detached-HEAD Integrate fix (room #21 J3, 2026-09-19; extended 2026-09-19 REVIEW): fixture A stages a detached HEAD (reviewed SHA + crew-init commit) and proves integrate leaves HEAD detached, keeps the reviewed SHA and crew-init as ancestors, lands the task merge, never moves main, records `integration_target=HEAD`, verify-merge VERIFIEDs, and push-target reports NO_REMOTE_PUSH (fixture A has no origin); fixture B proves the branch path still merges, reconciles, and pushes inline to a local origin; fixture C proves verify-merge checks the record against the LIVE tip (a detached line reset past the merge does NOT verify; the branch-gone path verifies only when the recorded commit is an ancestor of the live tip); fixture D proves the no-remote branch path fails soft with NO_REMOTE_PUSH (exit 0); fixture E proves the CREW_STAGED_BASE guard (non-ancestor base → STAGED_BASE_MISMATCH before any merge; true base → STAGED_BASE_OK); fixture F proves merged-but-unpushed retry recovery (re-running integrate after a crashed push takes the lock and pushes instead of parking on MERGED_EMPTY); fixture F3 proves the blocker-35 stale-record case (branch moved backward after the recorded merge → STALE_MERGE fails closed: never pushed, never MERGED_EMPTY); fixture H proves the detached-HEAD audit REDO contract (2026-09-19) on a detached checkout with a local origin: push-destination fails closed when origin/HEAD is unset (genuinely unknowable destination), otherwise resolves the remote default branch; integrate pushes inline as `HEAD:main` (`PUSHED: origin/main (refspec HEAD:main)`), the remote ref advances to the merge commit, local main stays at the reviewed base, HEAD stays detached, verify-merge VERIFIEDs, and push-target re-pushes idempotently; fixture I proves the push-target identity contract on a branch with a local origin: I1 stale record → STALE_MERGE and the remote ref does not move, I2 branch restored to the merged tip → the legitimate retry still PUSHEDs, I3 the manual R5 shape (stale record plus a newer manual merge of the current tip) → PUSHEDs. Scratch dirs under /tmp only; CREW_HOME lives outside the scratch repos (as in real rooms) so lock files never trip the integrate preflight.
18
18
  - `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
19
19
  - `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)
20
20
  - `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
@@ -30,23 +30,19 @@ Shipped library: ESM JavaScript CLIs and import-safe modules, shell scripts for
30
30
  - `write-ooda-verdict.js` — deterministic writer for the OODA terminal verdict (2026-09-14, append-only ledger 2026-09-15): `node write-ooda-verdict.js --dir <phase-dir> --attempt <id> --verdict <PASS|FAIL|NOT_POSSIBLE> --summary <text> --expected <text> --actual <text> --missing <json-array> [--reason <text>]` writes `<phase-dir>/verdict.json` (the latest verdict) and appends one JSON line to `<phase-dir>/verdicts.jsonl` — the append-only ledger: every attempt's verdict is preserved with a mechanical `seq`, never overwritten; corrupt or non-contiguous ledgers fail loudly. `--reason` is REQUIRED and must be non-empty for `FAIL` and `NOT_POSSIBLE` — a reason-less negative verdict fails with exit 2 before anything is written (2026-09-15). Exit 2 on bad input. See `docs/ooda-report.md`.
31
31
  - `read-ooda-verdict.js` — deterministic cross-checker for the OODA terminal verdict (2026-09-15): `node read-ooda-verdict.js --dir <phase-dir> --expect <PASS|FAIL>` reads `<dir>/verdict.json`, prints one JSON line to stdout, exits 0 with `{ok:true, verdict, reason, summary, expected, actual, attempt}` when the record agrees with the prose expectation and a FAIL carries a non-empty reason, or exits 2 with `{ok:false, code}` — `missing|corrupt|contradiction|no_reason`. No wall-clock reads, no randomness. The bugfix QA closeout runs it against the prose `VERDICT:` line before any rework routing: a failed cross-check records the phase as failed for retry, never routes to rework.
32
32
  - `ux-doctrine.js` — UX-surface doctrine page resolution (2026-09-17): the canonical map from `environment_type` to the crew's shared UX bar (`artifact` → `docs/artifact-ux.md`, `terminal` → `docs/terminal-ux.md`; null/unknown → no page). Pure and deterministic: `doctrinePage(env)`, `doctrinePath(crewHome, env)`, `doctrinePageExists(crewHome, env)`; CLI `--page <env>` / `--path <crewHome> <env>`. Workflows mirror the map inline (one line — the workflow runtime's relative-import support is unverified) and tests pin the mirror against this file.
33
- - `serve-artifact.js` — local server for a built TS space for experiential QA (2026-09-14): serves `<space-dir>/client/dist` statically and dispatches POST `*/actions` to the compiled server actions with a locally-built Ctx. Prints `READY port=<n>` then serves until killed. Read-only w.r.t. the space directory. Fidelity: the served client and action handlers are the artifact's own built code; the Ctx is locally built (privileged handlers run from the space's own `server/dist/privileged.js` when present; blobs are stored in a per-run temp dir and served back at `/__blobs/<key>`); environment is inherited from the caller. It is not the hosted runtime — tasks that cannot be judged under it must report `NOT POSSIBLE: <reason>`.
34
- - `readback-disk.js` — deterministic publish content sensor (2026-09-16): reads the on-disk tree the artifact is built/served from and emits the machine-readable findings block (`FILE:`/`ADDED:`/`REMOVED:`/`END_FILE`) that `verify-publish.js` judges. The primary sensor — the LLM-inspector path (`build-readback-request.js`) is manual-fallback only since `artifact_inspect` was removed by the platform 2026-09-14.
35
- - `publish-content.js` — shared ESM content-primitives for publish verification (2026-09-18, blocker 15): diff parsing (`parseDiff`), findings parsing (`parseFindings`), old-tree occurrence counting (`makeOldCounter`), and the discriminating-line / collision-exemption logic (`discriminatingLines`). Unifies `verify-publish.js` and the unknown-recovery classifier on one judgment so the two paths can never disagree about what a diff proves.
36
- - `classify-publish-absence.js` — deterministic six-way classifier for publish-parked UNKNOWN outcomes (2026-09-18, blocker 15): decides from durable signals only — the pre-trigger manifest baseline (captured by the workflow into the submitted ledger entry; design §1.9) vs the current manifest's `built_at`/`content_sha256`, where "the trigger" is the D1 issuance instant (2026-09-19; entry_kind cut 2026-09-20): the oldest submitted ledger entry by `ts`, binding its `issued_at` (falling back to its `ts` when the capture was unobserved), `git diff <base> <commit>` discriminating lines against the on-disk source tree, and HEAD vs the publish commit. Outcomes: `provably-dropped` (source shows pre-edit state, manifest NOT advanced past the trigger, HEAD == commit, aged past quiesce — retry once), `verified` (manifest advanced past the trigger AND content_sha256 differs from the pre-trigger baseline — a new build identity, not a replayed manifest; falls back to the time-based advance check with a note when the baseline is absent, and the Step 4.5 verifier fails closed without a baseline), `applied-not-built` (platform build-emission failure; no retry — the 2026-09-12 re-trigger hazard), `ambiguous` (any inconclusive shape — no retry by design), `deferred` (build in flight — manifest changed during the content read, or built within the settle window — or park below quiesce; not a verdict, retry later), `superseded` (HEAD != commit — never retry the old commit). Retry budget is consumed by the classification itself, never by the edit attempt. Never reads the wall clock except for recovery timing; addition-only and removal-only diffs are vacuously satisfied on their empty side.
37
- - `build-readback-request.js` — builds the LLM-inspector read-back `verbatim_request` from the merge commit's diff (2026-09-14): carries the merged diff as the expected change and asks for an independent read of the artifact's actual source. Retained as the manual fallback; the deterministic `readback-disk.js` is the primary sensor.
38
- - `verify-publish.js` — mechanical publish verification judge (2026-09-14/16; shared primitives 2026-09-18): certifies the read-back findings block against `git diff` (strict `FILE:`/`ADDED:`/`REMOVED:`/`END_FILE` parsing, every discriminating added line PRESENT / every discriminating removed line ABSENT, HEAD==commit supersession check), then the design §1.9 manifest-freshness gate (current manifest `built_at` advanced past the trigger AND `content_sha256` differs from the workflow's pre-trigger baseline in the submitted ledger entry — a new build identity, not a replayed manifest; missing baseline fails closed), and only then stamps provenance. The trigger anchor is the D1 issuance instant (2026-09-19; entry_kind cut 2026-09-20): the oldest submitted ledger entry by `ts`, binding its `issued_at` (falling back to its `ts` when the capture was unobserved). Diff parsing, findings parsing, and the collision-exemption rules come from the shared `lib/publish-content.js` (the unknown-recovery classifier's own judgment — one definition, never two). Binary files, mode-only changes, and fully-colliding added hunks fail closed as `unverifiable-content` (2026-09-16, critic findings 1/5) — they can never vacuously stamp. Content-mismatch, unreadable-result, superseded, and stamp failures exit 1 with `publish: verification-failed` and no stamp.
33
+ - `qa-db.js` — fresh per-run QA database for the local artifact server (2026-09-21, blocker 32): `openQaDb(spaceDir)` opens a drizzle db over a fresh per-run SQLite database (temp dir, `node:sqlite`), migrated from the space's own `drizzle/` migrations in journal order — the same migration path a fresh production install takes — and never a copy of the shipped `app.db`, so QA writes can never contaminate production data (the B38 class). Driver: `drizzle-orm/sqlite-proxy` resolved from the space's own `node_modules` (the driver matches the artifact's drizzle version) over a `node:sqlite` proxy callback; zero extra crew-release dependencies. The proxy contract is verified against drizzle-orm 0.45.2's compiled runtime (positional value arrays for `all`/`values`, falsy `rows` on `get`-miss, per-item `{rows}` for batch). Throws with a clear reason when the space has no drizzle migrations or drizzle-orm is unresolvable — the caller degrades gracefully. Import-safe module (no shebang, no side effects, bare `node` exits 0).
34
+ - `serve-artifact.js` — local server for a built TS space for experiential QA (2026-09-14): serves `<space-dir>/client/dist` statically and dispatches POST `*/actions` to the compiled server actions with a locally-built Ctx. Prints `READY port=<n>` then serves until killed. SIGTERM/SIGINT close the listener and the QA db and exit (1.5s fallback). Read-only w.r.t. the space directory. Fidelity: the served client and action handlers are the artifact's own built code; the Ctx is locally built (`ctx.db` is a drizzle db over a fresh per-run database from `lib/qa-db.js` — never the shipped `app.db`; privileged handlers run from the space's own `server/dist/privileged.js` when present; blobs are stored in a per-run temp dir and served back at `/__blobs/<key>`); environment is inherited from the caller. It is not the hosted runtime — tasks that cannot be judged under it must report `NOT POSSIBLE: <reason>`.
35
+ - `publish-verification retired (0.14.6, 2026-09-20)` — the content-verdict/readback architecture is retired: `verify-publish.js`, `readback-disk.js`, `publish-content.js`, `classify-publish-absence.js`, `build-readback-request.js`, `check-intent-freshness.js`, and `retry-publish.js` are deleted. Eric's publication contract replaced it: exact per-attempt version acknowledgement is the sole positive completion criterion ("If we hear that the artifact acknowledges our version, that's it. We don't verify against content."). The version is derived deterministically (`compute-publish-diff.js --task-id/--attempt`), the tick worker issues the edit directly (`scan-publish-intent` → `record-intent-issuance`), and `scan-ack-pending` evaluates the version receipt with bounded retries. Provenance now certifies the issuance request was acknowledged, never byte equality.
39
36
  - `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
40
37
  - `gitignore.js` — deterministic .gitignore management for crew-owned paths (2026-09-17): the crew touches exactly one user-owned file outside `.orchestration/` — the repo's `.gitignore`. `ensureGitignoreEntries(repoPath, entries)` creates the file when missing, appends missing entries (exact line match, no duplicates), preserves existing content byte-for-byte, and is idempotent. `describeGitignoreChange(repoPath, entries)` renders the exact diff for the setup consent conversation. Crew-owned entries: `.worktrees/`, `.orchestration/user/`. CLI: `--repo <path> [--dry-run]`.
41
38
  - `repo-orchestration.js` — repository-local `.orchestration/` scaffold (2026-09-17): `scaffoldRepoOrchestration(repoPath, crewRepoPath)` creates `$REPO/.orchestration/{workflows,identities,phases,user}/`, seeds workflows/identities/phases from the crew repo's platform defaults with no-clobber semantics (existing project customizations never overwritten), and writes a README in `user/` explaining it's for local config. Idempotent. CLI: `--repo <path> --crew-repo <path>`.
42
39
  - `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 primary 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.
43
40
  - `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]`.
44
41
  - `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>`.
45
- - `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> <commit>` via argv (no shell) — `--commit <40-hex>` (2026-09-20, critic-0145 F-O2) pins the diff target deterministically; omitting it defaults to HEAD for the workflow's initial preparation. 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.
46
- - `check-intent-freshness.js` — deterministic intent-freshness helper (2026-09-20, critic-0145 F-O3/F-R2): on a re-claimed publish intent, the tick must decide whether the dead tick's edit already landed before re-issuing — prose judgment is forbidden. `node check-intent-freshness.js --slug <slug> --manifest-before '<json>' [--spaces-root <path>]` reads `<spaces-root>/<slug>/.space-build/manifest.json` (the same path the verifier uses) and prints exactly one JSON object `{ok, advanced, baseline:{built_at,content_sha256}, current:{built_at,content_sha256}}`; `advanced` is true when the content hash changed or `built_at` advanced past the baseline. Unreadable/missing manifest is `ok:false` (exit 1) — the freshness question is unanswerable, so the tick records nothing and lets the claim expire. The helper's stdout JSON is passed verbatim as `freshness_evidence` to `record-intent-issuance ... edit_outcome=recovered`; the command refuses a recovery without `advanced:true` evidence.
42
+ - `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> <commit>` via argv (no shell) — `--commit <40-hex>` (2026-09-20, critic-0145 F-O2) pins the diff target deterministically; omitting it defaults to HEAD for the workflow's initial preparation. 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`, `version`). 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. `--task-id <id> --attempt <n>` (2026-09-20, 0.14.6 §1.1 publish version, paired flags — one without the other is a usage error): derives the per-attempt version `commit + ":" + attempt` (plain concatenation — no hash, no wall clock, no randomness; 2026-09-20 the sha256 "nonce" was cut — it defended against a forger that doesn't exist while the artifact's real failure mode defeats hash and counter identically; keyed on the resolved commit SHA so the tick's byte-identical regeneration yields identical bytes) and appends a SYNTHETIC new-file hunk for `.crew-publish-version-<task_id>` (per-task receipt scope — two same-project tasks can publish concurrently; content = version + "\n", index line carries the real receipt blob hash via `git hash-object`) to the staged diff — synthesized into the staged artifact only, never committed; the checksum covers the hunk; empty real diffs carry no receipt (the workflow parks empty diffs). 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.
47
43
  - `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.
48
44
  - `advance-publish-base.js` — publish-base advance past the setup scaffold (2026-09-18, room #14; testimony-free 2026-09-19 REVIEW): `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 resolves the scaffold commit itself (`git rev-parse HEAD` in `--repo`; `--scaffold-sha` is an explicit override only) and moves `source_commit` to it only when every fail-closed condition holds deterministically: the existing stamp is not a real publish's (provenance.task_id is set by every real publish and null for initial stamps/base advances — the task_id invariant documented on stampProvenance; the old `--project-created` testimony flag is gone), 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. Every stdout JSON object also carries `repo_clean` (informational — whether `git status --porcelain` is empty — so the caller can warn loudly about a dirty tree; the first task's Integrate refuses to merge onto one). 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> --project-id <id> [--scaffold-sha <sha>]`.
49
45
  - `scaffold-crew.js` — deterministic crew-home scaffold composer (2026-09-19, Room #19 D1): replaces the agent-authored scaffold report (the agent returned prose instead of the schema'd JSON and the platform rejected it, killing room #19's init on minute one — the copying itself always succeeded). The script copies the fixed file sets (identities *.md+*.png, personas *.md, seed/workflows *.md, seed/feedback/README.md) into `<orchDir>/{identities,personas,workflows,feedback}/` with mkdir -p + no-clobber semantics (existing files never overwritten), verifies every expected file on disk (the decision rule), then writes `<orchDir>/.scaffold-receipt.json` `{created, skipped}` (integer counts, durable evidence) and prints the same JSON to stdout. Idempotent: all-skipped is exit 0. Exit 1 with the reason on stderr on any failure; exit 2 on usage errors. crew-init's scaffold-1 call is a byte courier only — it runs this script and prints the receipt's exact bytes; a paraphrased ferry fails the mechanical parse loudly as a blocked init (tripwire, never a fabricated 0/0). The receipt bytes are the agent's report of script output, not proof the script ran: crew-init corroborates with a witness re-run of this same idempotent composer (a witness that still has files to create proves the ferry's work didn't land; the witness's own disk-verification then holds the end state). Fail-closed source guards (2026-09-19 REVIEW): a missing
50
46
  or empty identities/, personas/, or seed/workflows/ source refuses loudly (exit 1,
51
47
  naming the source) before creating anything — never an empty 0/0 receipt. No wall-clock reads, no randomness.
52
- - `publish-note-vocabulary.js` — terminal-note vocabulary registry (2026-09-19, D7): the closed enum of every `publish: …` note the state machine treats as terminally parked (`TERMINAL_PUBLISH_NOTES`, `TERMINAL_NOTE_MEANINGS`, `matchTerminalPublishNote`). Readers (`scan-publish-unknown` in `crew-api.js`) consult it instead of an inline pattern list — a recognized terminal note is skipped as terminal with its meaning named, never as `unrecognized-publish-note`; writers (`verify-publish.js`'s `terminal()`) assert their emitted verb is in the registry before writing, so no future terminal verb ships unrecognized. The closed enum is pinned structurally by `tests/publish-note-vocabulary.test.js` (every `publish: <verb>` literal in lib/ declared in the registry or the pinned transitional set). Pinned with the other runtime-relative dependencies of `crew-api.js` in the workflows' `pinLifecycle` (`NOTE_VOCAB`), enforced by `tests/pin-closure.test.js`. Extensible closed-enum pattern: the first instance of the enum-guard family the audit (§3) extends to the other state-carrying string vocabularies. No side effects on import (bare `node` exits 0).
48
+ - `publish-note-vocabulary.js` — publish-note vocabulary registry (2026-09-19, D7; 0.14.6 closed writer, 2026-09-20; code/prose write split 2026-09-20 REVIEW): the closed enum of every `publish: …` note the state machine treats as terminally parked (`TERMINAL_PUBLISH_NOTES`, `TERMINAL_NOTE_MEANINGS`, `matchTerminalPublishNote`) plus the transitional notes the 0.14.6 machine writes (`TRANSITIONAL_PUBLISH_NOTES`). Write authority is split: `WRITABLE_PUBLISH_NOTES` is transitional-only (tick prose via `record-publish-note` — prose can never mint a terminal); `CODE_WRITABLE_PUBLISH_NOTES` holds the current terminals (deterministic code via `assertCodeWritablePublishNote`); legacy terminals are recognized but never written anew. `log-event` mechanically rejects `type:"note"` messages containing `publish: <verb>` — they must route through `record-publish-note` or deterministic code. Readers (the ack/intent scans in `crew-api.js`) consult it instead of an inline pattern list — a recognized terminal note is skipped as terminal with its meaning named, never as `unrecognized-publish-note`; legacy verbs from the retired content-verdict machine stay recognized so old note history never becomes unrecognized. Writers assert via `assertWritablePublishNote` (prose) or `assertCodeWritablePublishNote` (code) before writing; the only prose path is the `record-publish-note` crew-api command, which asserts here. The closed enum is pinned structurally by `tests/publish-note-vocabulary.test.js` (every `publish: <verb>` literal in lib/ declared in the registry or the pinned transitional set). Pinned with the other runtime-relative dependencies of `crew-api.js` in the workflows' `pinLifecycle` (`NOTE_VOCAB`), enforced by `tests/pin-closure.test.js`. No side effects on import (bare `node` exits 0).
@@ -11,7 +11,7 @@
11
11
  // This script moves provenance.source_commit to the scaffold commit only when
12
12
  // every fail-closed condition holds:
13
13
  // 1. The existing stamp is not a real publish's: provenance.task_id is set
14
- // by every real publish (lib/verify-publish.js and the workflow
14
+ // by every real publish (the version ack scan and the workflow
15
15
  // provenance-refresh paths) and is null for initial stamps and for this
16
16
  // script's own base advances. A stamp with task_id set is never moved.
17
17
  // (2026-09-19 REVIEW: this deterministic check replaces the old
@@ -293,8 +293,8 @@ function main() {
293
293
  console.error("set-provenance did not return ok");
294
294
  process.exit(1);
295
295
  }
296
- // Exact read-back: the stamp must match what we intended, or it is fiction
297
- // (same discipline as lib/verify-publish.js).
296
+ // Exact match: the stamp must match what we intended, or it is fiction
297
+ // (same discipline as the version ack scan's stamp).
298
298
  let recheck;
299
299
  try {
300
300
  recheck = crewApi(crewHome, "get-provenance", JSON.stringify({ project_id: projectId })).provenance;
@@ -17,8 +17,7 @@
17
17
  // below" for a terminal-surface task with no audit dir at all, and opened
18
18
  // with "these screenshots are dropping in" when no screenshots existed. So
19
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.
20
+ // /audits/<auditDir>/, the same root lib/crew-api.js resolves — and checks screenshot.png + screenshot-mobile.png are files.
22
21
  // Missing deploy_slug, missing dir, or missing PNGs gets the honest
23
22
  // no-captures line; terminal surfaces are named, not assumed.
24
23
  //
@@ -14,7 +14,8 @@
14
14
  // small JSON summary verbatim. The diff bytes live in a file; the LLM
15
15
  // never carries them:
16
16
  //
17
- // node compute-publish-diff.js --repo-path <path> --base <sha> --out <file> [--commit <sha>]
17
+ // node compute-publish-diff.js --repo-path <path> --base <sha> --out <file>
18
+ // [--commit <sha>] [--task-id <id> --attempt <n>]
18
19
  //
19
20
  // --base is the previously-stamped provenance source_commit (or the
20
21
  // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
@@ -30,9 +31,24 @@
30
31
  // the diff range, and the reported `commit` all target --commit, never an
31
32
  // implicit HEAD.
32
33
  //
34
+ // --task-id and --attempt (2026-09-20, 0.14.6 §1.1 publish version): when
35
+ // BOTH are given, the script derives a per-attempt publish version
36
+ // version = commit + ":" + attempt (e.g. "abc123…:2")
37
+ // (deterministic — no wall clock, no randomness, no hash; the resolved
38
+ // --commit SHA, never an implicit HEAD, is the derivation input so the
39
+ // tick's byte-identical regeneration yields the identical version) and
40
+ // appends a SYNTHETIC new-file hunk for `.crew-publish-version-<task_id>`
41
+ // (content = version + "\n") to the staged diff. The version is protocol,
42
+ // not product: never in the repo — synthesized into the staged artifact
43
+ // only — and the staged diff's checksum covers the hunk. An empty real diff
44
+ // gets no hunk (the workflow parks empty diffs; a version-only publish is
45
+ // never a request). Either flag without the other, a non-positive-integer
46
+ // attempt, or an empty task id fails with usage (exit 2).
47
+ //
33
48
  // Stdout contract: exactly one JSON object, always small (no diff text):
34
49
  // { ok:true, commit, base, ancestor:"yes", diff_file, sha256, bytes,
35
- // file_count, changed_lines, files:[<paths>], has_binary, has_rename }
50
+ // file_count, changed_lines, files:[<paths>], has_binary, has_rename,
51
+ // version } (version is "<commit>:<attempt>" when synthesized, null otherwise)
36
52
  // { ok:false, error:"not-ancestor", commit, base } (semantic, exit 0)
37
53
  //
38
54
  // Exit codes: 0 ok (JSON on stdout) · 2 usage/validation · 1 git/diff/IO
@@ -51,7 +67,7 @@ import { dirname, resolve } from "node:path";
51
67
  // --help: before required-arg parsing (shebang⇔CLI contract).
52
68
  if (process.argv.slice(2).includes("--help")) {
53
69
  console.log(
54
- "usage: node compute-publish-diff.js --repo-path <path> --base <sha> --out <file> [--commit <sha>]\n" +
70
+ "usage: node compute-publish-diff.js --repo-path <path> --base <sha> --out <file> [--commit <sha>] [--task-id <id> --attempt <n>]\n" +
55
71
  "runs git diff <base> <commit> (default <commit> = HEAD), writes the raw diff bytes to --out, prints the small JSON summary on stdout"
56
72
  );
57
73
  process.exit(0);
@@ -83,6 +99,24 @@ const repoPath = arg("--repo-path");
83
99
  const base = arg("--base");
84
100
  const outFile = arg("--out");
85
101
  const commitArg = arg("--commit", false);
102
+ const taskIdArg = arg("--task-id", false);
103
+ const attemptArg = arg("--attempt", false);
104
+
105
+ // 0.14.6 §1.1: --task-id and --attempt are a pair — the version is derived
106
+ // from both, so one without the other is a caller bug, not a default.
107
+ if ((taskIdArg === null) !== (attemptArg === null)) {
108
+ fail("usage", "--task-id and --attempt must be provided together.", 2);
109
+ }
110
+ let taskId = null;
111
+ let attempt = null;
112
+ if (taskIdArg !== null) {
113
+ taskId = String(taskIdArg).trim();
114
+ if (!taskId) fail("usage", "--task-id must be a non-empty task id.", 2);
115
+ if (!/^[1-9][0-9]*$/.test(attemptArg)) {
116
+ fail("usage", "--attempt must be a positive integer (the 1-based issuance attempt).", 2);
117
+ }
118
+ attempt = attemptArg;
119
+ }
86
120
 
87
121
  if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "--base must be a 40-char hex commit SHA (or the empty-tree SHA).", 2);
88
122
  let st = null;
@@ -125,7 +159,47 @@ if (ancestor !== "yes") {
125
159
  const outPath = resolve(outFile);
126
160
  mkdirSync(dirname(outPath), { recursive: true });
127
161
  const diffText = git(repoPath, ["diff", base, commit]);
128
- writeFileSync(outPath, diffText, "utf8");
162
+
163
+ // 0.14.6 §1.1 — the publish version. The diff transport is the handoff, so
164
+ // the staged artifact carries its own receipt: a synthetic new-file hunk
165
+ // for `.crew-publish-version-<task_id>` whose content is the per-attempt
166
+ // version. The receipt is scoped per task (not per slug): two tasks of the
167
+ // same project can sit in `edit-issued` concurrently, and a shared
168
+ // per-slug receipt would let each issuance overwrite the other's
169
+ // acknowledgement. The derivation is a plain concatenation — commit + ":" +
170
+ // attempt — no wall clock, no randomness, no hash, keyed on the RESOLVED
171
+ // commit SHA, so the tick's byte-identical regeneration yields the
172
+ // identical version and the identical staged bytes. The index line
173
+ // carries the real git blob hash of the receipt content (via
174
+ // hash-object, deterministic) — the hunk is honest, not a mock-up. The
175
+ // receipt content is never committed to the repo: it is synthesized here,
176
+ // into the staged artifact only. The staged-diff checksum (below) covers
177
+ // the hunk by construction. An empty real diff carries no receipt — the
178
+ // workflow parks empty diffs before issuance, and a version-only staged
179
+ // diff would defeat that park.
180
+ let version = null;
181
+ let stagedDiff = diffText;
182
+ if (taskId !== null && diffText.length > 0) {
183
+ version = commit + ":" + attempt;
184
+ const receiptContent = version + "\n";
185
+ const blobSha = execFileSync("git", ["-C", repoPath, "hash-object", "--stdin"], {
186
+ input: receiptContent, encoding: "utf8", stdio: ["pipe", "pipe", "ignore"],
187
+ }).trim();
188
+ if (!/^[0-9a-f]{40}$/.test(blobSha)) {
189
+ fail("git-failed", "could not hash the synthetic version receipt.", 1);
190
+ }
191
+ const receiptName = ".crew-publish-version-" + taskId;
192
+ stagedDiff =
193
+ diffText +
194
+ "diff --git a/" + receiptName + " b/" + receiptName + "\n" +
195
+ "new file mode 100644\n" +
196
+ "index 0000000.." + blobSha + "\n" +
197
+ "--- /dev/null\n" +
198
+ "+++ b/" + receiptName + "\n" +
199
+ "@@ -0,0 +1 @@\n" +
200
+ "+" + version + "\n";
201
+ }
202
+ writeFileSync(outPath, stagedDiff, "utf8");
129
203
  const bytes = readFileSync(outPath);
130
204
  const sha256 = createHash("sha256").update(bytes).digest("hex");
131
205
 
@@ -143,7 +217,7 @@ let added = 0;
143
217
  let removed = 0;
144
218
  let has_binary = false;
145
219
  let has_rename = false;
146
- for (const line of diffText.split("\n")) {
220
+ for (const line of stagedDiff.split("\n")) {
147
221
  const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
148
222
  if (m) {
149
223
  current = m[2];
@@ -172,4 +246,7 @@ process.stdout.write(JSON.stringify({
172
246
  files,
173
247
  has_binary,
174
248
  has_rename,
249
+ // 0.14.6 §1.1: the per-attempt publish version ("<commit>:<attempt>" when synthesized,
250
+ // null when --task-id/--attempt were omitted or the diff was empty).
251
+ version,
175
252
  }) + "\n");