muse-crew 0.14.0 → 0.14.2

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/AGENTS.md CHANGED
@@ -18,4 +18,4 @@ Muse Crew source repository. The repo is the product; the personal instance (`$C
18
18
  - Git source is authoritative.
19
19
  - Never expose this repo publicly.
20
20
  - Ship implementation and documentation together.
21
- - After editing `workflows/*.js`, run `bash tests/run.sh` — `node --check` does NOT catch syntax errors inside function bodies (V8 lazy preparsing), so it cannot validate workflow edits alone.
21
+ - After editing `workflows/*.js`, run `bash tests/run.sh` — `node --check` does NOT catch syntax errors inside function bodies (V8 lazy preparsing), so it cannot validate workflow edits alone. Worse (2026-09-19): raw `node --check` on the unstripped file is a FALSE NEGATIVE — the file parses as a module (top-level `export`), which masks breakage the release gate's export-strip + async-wrap transform exposes (module/script goal confusion). An unterminated string passed raw `node --check` and was caught only by `publish-verdict-first.test.js`'s loader emulation. After any workflow edit, verify with the true gate: `{ echo "async function __crew_workflow__(args) {"; sed 's/^export //' workflows/<f>.js; echo "}"; } > /tmp/w.js && node --check /tmp/w.js` — or just run the suite.
@@ -1125,15 +1125,15 @@ Applies to: standard, bugfix, chore.
1125
1125
  ```
1126
1126
  // Deterministic integrate verification: the agent cannot self-certify a
1127
1127
  // merge. After the Integrate agent claims success, the workflow confirms
1128
- // mechanically that the task branch tip is an ancestor of main via the
1128
+ // mechanically that the task branch tip is an ancestor of the integration target via the
1129
1129
  // lifecycle script's verify-merge command (which resolves the branch
1130
1130
  // through the crew registry — never by reconstructing "task/"+taskId — so
1131
1131
  // the check cannot verify the wrong branch). The VERIFIED marker is matched
1132
1132
  // by regex on the script's own stdout; agent prose is never read. This
1133
1133
  // closes the hole where an agent reported "merged empty" while approved
1134
1134
  // commits were still stranded on the task branch (bug b1b1f919). A genuine
1135
- // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1136
- // vacuously — the tip is then an ancestor of main. Verification failure is
1135
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of the integration target) verifies
1136
+ // vacuously — the tip is then an ancestor of the integration target. Verification failure is
1137
1137
  // an operational step failure, not a park: the dispatcher retries Integrate
1138
1138
  // under its consecutive-failure cap, and the retry finds the commits still
1139
1139
  // on the branch and performs the real merge — self-healing.
@@ -1149,14 +1149,14 @@ Applies to: standard, bugfix, chore.
1149
1149
  ```
1150
1150
  // Already-merged idempotency: a `repo_diff: none (already-merged:
1151
1151
  // <sha>)` declaration is verified mechanically — <sha> must resolve
1152
- // and be an ancestor of main in the configured repo. A fabricated or
1152
+ // and be an ancestor of the integration target in the configured repo. A fabricated or
1153
1153
  // mistaken declaration fails the phase here (the dispatcher retries
1154
1154
  // Build under its consecutive-failure cap); a verified declaration is
1155
1155
  // recorded in alreadyMergedSha for Review's no-diff branch. Without
1156
1156
  // this guard, Build correctly doing nothing left Review with no
1157
1157
  // mechanical way to accept an empty diff, and Cass rejected for "no
1158
- // commits ahead of main — the builder likely forgot to commit" while
1159
- // the deliverable sat on main (canary 2026-09-15, task 1d692d91).
1158
+ // commits ahead of the integration target — the builder likely forgot to commit" while
1159
+ // the deliverable sat on the integration target (canary 2026-09-15, task 1d692d91).
1160
1160
  // The sha is hex-only by construction (extractAlreadyMerged), so
1161
1161
  // interpolating it into the shell command cannot inject.
1162
1162
  ```
@@ -44,7 +44,7 @@ Applies to: standard, bugfix, chore.
44
44
  // that parks or fails must not leak its worktree, branch, or merge lock.
45
45
  // The lifecycle's terminal-cleanup releases the lock unconditionally and
46
46
  // reclaims the worktree+branch ONLY when the task branch is fully merged
47
- // into main (then it is redundant); unmerged work is preserved for the
47
+ // into the integration target (then it is redundant); unmerged work is preserved for the
48
48
  // human by design. Fire-and-forget with one bounded retry — the merge-lock
49
49
  // lease expiry and the orphan sweep are the backstop for a dead transport.
50
50
  ```
package/docs/guide.md CHANGED
@@ -201,7 +201,7 @@ If you don't have a project in mind, the crew can set up a sample: a tiny flashc
201
201
 
202
202
  ### Worktrees
203
203
 
204
- Each task builds in an isolated git worktree at `<repo>/.worktrees/<task_id>`, on a branch named `task/<task_id>`. The lifecycle manager (`lib/worktree-lifecycle.sh`) owns creation and removal — workflows and agents never run raw `git worktree` commands. The crew's own task→worktree registry (`<repo>/.worktrees/.registry/`) is the source of truth for task→branch/path; `prepare` fails closed when `main` is dirty, and `cleanup` is forgiving so a failed run never strands a worktree.
204
+ Each task builds in an isolated git worktree at `<repo>/.worktrees/<task_id>`, on a branch named `task/<task_id>`. The lifecycle manager (`lib/worktree-lifecycle.sh`) owns creation and removal — workflows and agents never run raw `git worktree` commands. The crew's own task→worktree registry (`<repo>/.worktrees/.registry/`) is the source of truth for task→branch/path; `prepare` fails closed when the integration target is dirty, and `cleanup` is forgiving so a failed run never strands a worktree.
205
205
 
206
206
  ## Filing a task
207
207
 
@@ -336,7 +336,7 @@ Configuration (per project, via the existing `getconfig` / `updateconfig` action
336
336
 
337
337
  The crew upgrades itself through the normal dispatch loop. An upgrade task carries a `source:` line in its description naming where the new release comes from:
338
338
 
339
- - `source: repo` (default when absent) — deploys the task project's repo at its current HEAD. This is how you ship repo changes to your running crew: change the code, merge to main, then file an upgrade task pointing at that repo.
339
+ - `source: repo` (default when absent) — deploys the task project's repo at its current HEAD. This is how you ship repo changes to your running crew: change the code, merge into the integration target (the repo's current checkout), then file an upgrade task pointing at that repo.
340
340
  - `source: npm@x.y.z` — installs the published `muse-crew` package at that exact version from the public npm registry. Use this to move the crew onto a known-good release — including one that predates the upgrade workflow itself.
341
341
 
342
342
  Both paths deploy through the stable `crew-release.sh` on persistent disk (never the release under upgrade), then verify mechanically: the live release equals the target, the release registry parses, and `crew-api get-state` works. Handover is automatic — the next polling tick launches the dispatcher from the new release through the `current` symlink. In-flight runs finish on the old release via the per-task lib pin, so an upgrade mid-flight does not break running work.
@@ -379,7 +379,7 @@ Each phase has an assigned identity — a character with a defined personality:
379
379
  | Map | **Mara** | Designer |
380
380
  | Build | **Wren** | Quietest one, trusts the plan |
381
381
  | Review | **Cass** | Fair but exacting — holds the spec as the contract |
382
- | Integrate | **Wren** | Merges the work, pushes `main` to the repo (succeeds vacuously when the task branch is empty — runtime-state deliverable) |
382
+ | Integrate | **Wren** | Merges the work into the integration target and pushes it (succeeds vacuously when the task branch is empty — runtime-state deliverable) |
383
383
  | Publish | **Wren** | Ships the merged code to the publish target (skipped when none). The workflow verifies the side effect mechanically — npm via registry version; artifact via an independent content read-back before the parent stamps provenance (see `docs/publish-verification.md`) — and fails closed if the worker's report and system state disagree |
384
384
  | QA | **Hazel** | Code-blind, persistent, wears persona costumes |
385
385
  | Reproduce | **Hazel** | Reproduces bugs before fixing |
package/lib/AGENTS.md CHANGED
@@ -7,7 +7,8 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
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. 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.
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 (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 / PUSH_SKIPPED (detached HEAD — the line is local by design, a policy choice not a git limitation) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). 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 reconcile loudly (NO_REMOTE_RECONCILE) — room #21 J3, 2026-09-19 — fail-soft with no remote, fail closed on conflict; `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.
11
+ - `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 PUSH_SKIPPEDs; 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). Scratch dirs under /tmp only; CREW_HOME lives outside the scratch repos (as in real rooms) so lock files never trip the integrate preflight.
11
12
  - `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
13
  - `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
14
  - `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
@@ -31,9 +32,12 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
31
32
  - `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`.
32
33
  - `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]`.
33
34
  - `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>`.
34
- - `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's main checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
35
+ - `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.
35
36
  - `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]`.
36
37
  - `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>`.
37
38
  - `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.
38
39
  - `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.
39
- - `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]`.
40
+ - `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>]`.
41
+ - `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
42
+ or empty identities/, personas/, or seed/workflows/ source refuses loudly (exit 1,
43
+ naming the source) before creating anything — never an empty 0/0 receipt. No wall-clock reads, no randomness.
@@ -10,8 +10,14 @@
10
10
  //
11
11
  // This script moves provenance.source_commit to the scaffold commit only when
12
12
  // every fail-closed condition holds:
13
- // 1. --project-created: this init created the project, so the existing stamp
14
- // is this run's initial stamp — never a real publish's.
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
15
+ // provenance-refresh paths) and is null for initial stamps and for this
16
+ // script's own base advances. A stamp with task_id set is never moved.
17
+ // (2026-09-19 REVIEW: this deterministic check replaces the old
18
+ // --project-created flag, which was agent testimony ferried through the
19
+ // workflow. The task_id invariant is documented on stampProvenance in
20
+ // lib/crew-api.js.)
15
21
  // 2. The scaffold commit's parent IS the stamped source_commit: nothing
16
22
  // slipped between the stamp and the scaffold, so no un-published task
17
23
  // work can be silently dropped from a future diff.
@@ -19,10 +25,17 @@
19
25
  // (.orchestration/ or .gitignore).
20
26
  // 4. Special case: no provenance was ever stamped (empty repo at create
21
27
  // time) and the scaffold commit is the root commit -> stamp fresh.
28
+ // The scaffold commit is resolved by this script itself (git rev-parse HEAD
29
+ // in --repo) unless --scaffold-sha overrides it — the caller never ferries
30
+ // the sha through agent testimony (2026-09-19 REVIEW: agent-mechanic boundary
31
+ // razor — consequential parameters come from deterministic reads, not
32
+ // testimony).
22
33
  // Anything else yields { ok: true, action: "skipped", reason } — never an
23
34
  // error, never a clobber. stdout carries exactly one JSON object; operational
24
35
  // failures go to stderr with a non-zero exit (the caller treats failure as
25
- // best-effort: init continues with the stamped base).
36
+ // best-effort: init continues with the stamped base). Every JSON object also
37
+ // carries repo_clean (boolean): whether `git status --porcelain` in --repo
38
+ // is empty — informational for the caller, never a gate.
26
39
  //
27
40
  // No wall-clock reads, no randomness — the published_at on a fresh stamp is
28
41
  // left to crew-api's default; on an advance the existing published_at is
@@ -39,7 +52,7 @@ const RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
39
52
 
40
53
  function usage() {
41
54
  console.error(
42
- "Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --scaffold-sha <sha> --project-id <id> --project-created"
55
+ "Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --project-id <id> [--scaffold-sha <sha>]"
43
56
  );
44
57
  }
45
58
 
@@ -51,7 +64,6 @@ function parseArgv(argv) {
51
64
  else if (a === "--repo") out.repo = argv[++i];
52
65
  else if (a === "--scaffold-sha") out.scaffoldSha = argv[++i];
53
66
  else if (a === "--project-id") out.projectId = argv[++i];
54
- else if (a === "--project-created") out.projectCreated = true;
55
67
  else {
56
68
  console.error("unknown argument: " + a);
57
69
  usage();
@@ -90,25 +102,47 @@ function isCrewOwnedPath(p) {
90
102
  return p === ".gitignore" || p === ".orchestration" || p.startsWith(".orchestration/");
91
103
  }
92
104
 
93
- function skip(reason) {
94
- console.log(JSON.stringify({ ok: true, action: "skipped", reason }));
105
+ function skip(reason, repoClean) {
106
+ console.log(JSON.stringify({ ok: true, action: "skipped", reason, repo_clean: repoClean }));
95
107
  }
96
108
 
97
109
  function main() {
98
- const { crewHome, repo, scaffoldSha, projectId, projectCreated } = parseArgv(
99
- process.argv.slice(2)
100
- );
101
- if (!crewHome || !repo || !scaffoldSha || !projectId) {
110
+ const parsed = parseArgv(process.argv.slice(2));
111
+ const { crewHome, repo, projectId } = parsed;
112
+ let scaffoldSha = parsed.scaffoldSha || null;
113
+ if (!crewHome || !repo || !projectId) {
102
114
  usage();
103
115
  process.exit(2);
104
116
  }
105
- if (!SHA_RE.test(scaffoldSha)) {
106
- console.error("scaffold-sha is not a 40-char hex sha");
107
- process.exit(2);
117
+ // The scaffold commit is resolved by this script, not ferried through the
118
+ // caller: --scaffold-sha is an explicit override; otherwise HEAD is read
119
+ // directly from the repo.
120
+ if (scaffoldSha) {
121
+ if (!SHA_RE.test(scaffoldSha)) {
122
+ console.error("scaffold-sha is not a 40-char hex sha");
123
+ process.exit(2);
124
+ }
125
+ } else {
126
+ try {
127
+ scaffoldSha = git(repo, ["rev-parse", "HEAD"]);
128
+ } catch (e) {
129
+ console.error("rev-parse HEAD failed: " + (e.message || e));
130
+ process.exit(1);
131
+ }
132
+ if (!SHA_RE.test(scaffoldSha)) {
133
+ console.error("rev-parse HEAD did not print a 40-char hex sha");
134
+ process.exit(1);
135
+ }
108
136
  }
109
- if (!projectCreated) {
110
- skip("project was not created by this init — provenance left untouched");
111
- return;
137
+
138
+ // Informational for the caller (never a gate): whether the repo worktree
139
+ // is clean. The caller warns loudly on a dirty tree — the first task's
140
+ // Integrate refuses to merge onto one.
141
+ let repoClean = null;
142
+ try {
143
+ repoClean = git(repo, ["status", "--porcelain"]) === "";
144
+ } catch (e) {
145
+ repoClean = null;
112
146
  }
113
147
 
114
148
  let provenance;
@@ -119,6 +153,19 @@ function main() {
119
153
  process.exit(1);
120
154
  }
121
155
 
156
+ // Deterministic, not testimony: every real publish stamps provenance with
157
+ // its task_id (see the task_id invariant on stampProvenance in
158
+ // lib/crew-api.js); initial stamps and base advances leave it null. A
159
+ // publish's base is never moved.
160
+ if (provenance && provenance.task_id) {
161
+ skip(
162
+ "provenance was stamped by a real publish (task_id " + provenance.task_id +
163
+ ") — a publish's base is never moved",
164
+ repoClean
165
+ );
166
+ return;
167
+ }
168
+
122
169
  // The scaffold commit's parent: empty output means S is the root commit.
123
170
  let parent;
124
171
  try {
@@ -144,8 +191,7 @@ function main() {
144
191
  skip(
145
192
  "scaffold commit touches non-crew-owned paths (" +
146
193
  foreign.slice(0, 5).join(", ") +
147
- ") — provenance left untouched"
148
- );
194
+ ") — provenance left untouched", repoClean);
149
195
  return;
150
196
  }
151
197
 
@@ -154,18 +200,18 @@ function main() {
154
200
  // scaffold commit is the root commit — anything else means history we
155
201
  // did not create, and we do not invent a base over it.
156
202
  if (parent !== null) {
157
- skip("no provenance stamped and scaffold commit is not the root commit — provenance left untouched");
203
+ skip("no provenance stamped and scaffold commit is not the root commit — provenance left untouched", repoClean);
158
204
  return;
159
205
  }
160
206
  let crewRelease = null;
161
207
  try {
162
208
  crewRelease = resolveActiveRelease(crewHome);
163
209
  } catch (e) {
164
- skip("no provenance stamped and the active crew release is unresolvable (" + (e.message || e) + ")");
210
+ skip("no provenance stamped and the active crew release is unresolvable (" + (e.message || e) + ")", repoClean);
165
211
  return;
166
212
  }
167
213
  if (!RELEASE_RE.test(crewRelease)) {
168
- skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")");
214
+ skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")", repoClean);
169
215
  return;
170
216
  }
171
217
  let stamped;
@@ -183,17 +229,17 @@ function main() {
183
229
  console.error("set-provenance did not return ok");
184
230
  process.exit(1);
185
231
  }
186
- console.log(JSON.stringify({ ok: true, action: "stamped", to: scaffoldSha.slice(0, 12) }));
232
+ console.log(JSON.stringify({ ok: true, action: "stamped", to: scaffoldSha.slice(0, 12), repo_clean: repoClean }));
187
233
  return;
188
234
  }
189
235
 
190
236
  const base = provenance.source_commit;
191
237
  if (!SHA_RE.test(base || "")) {
192
- skip("existing provenance.source_commit is not a 40-char hex sha — provenance left untouched");
238
+ skip("existing provenance.source_commit is not a 40-char hex sha — provenance left untouched", repoClean);
193
239
  return;
194
240
  }
195
241
  if (base === scaffoldSha) {
196
- skip("provenance already points at the scaffold commit");
242
+ skip("provenance already points at the scaffold commit", repoClean);
197
243
  return;
198
244
  }
199
245
  if (parent !== base) {
@@ -202,8 +248,7 @@ function main() {
202
248
  String(parent || "root").slice(0, 12) +
203
249
  ") is not the stamped base (" +
204
250
  base.slice(0, 12) +
205
- ") — provenance left untouched"
206
- );
251
+ ") — provenance left untouched", repoClean);
207
252
  return;
208
253
  }
209
254
 
@@ -219,7 +264,7 @@ function main() {
219
264
  }
220
265
  }
221
266
  if (!crewRelease) {
222
- skip("no usable crew_release for the re-stamp — provenance left untouched");
267
+ skip("no usable crew_release for the re-stamp — provenance left untouched", repoClean);
223
268
  return;
224
269
  }
225
270
 
@@ -259,6 +304,7 @@ function main() {
259
304
  action: "advanced",
260
305
  from: base.slice(0, 12),
261
306
  to: scaffoldSha.slice(0, 12),
307
+ repo_clean: repoClean,
262
308
  })
263
309
  );
264
310
  }
package/lib/crew-api.js CHANGED
@@ -2625,6 +2625,14 @@ commands["update-config"] = (db, args) => {
2625
2625
  // projects carries N independent publish bases, and stamping one project
2626
2626
  // never touches another's row.
2627
2627
  function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
2628
+ // task_id invariant (2026-09-19 REVIEW): task_id is set by every REAL
2629
+ // publish (lib/verify-publish.js and the workflow provenance-refresh paths)
2630
+ // and is null for initial stamps (tryStampInitialProvenance) and for
2631
+ // publish-base advances (lib/advance-publish-base.js). So
2632
+ // provenance.task_id != null ⟺ a real publish has stamped this project.
2633
+ // lib/advance-publish-base.js relies on this to deterministically refuse to
2634
+ // move a publish's base. Keep it true: any new real-publish path must pass
2635
+ // its task_id; anything that is not a publish must leave it null.
2628
2636
  const pid = String(project_id ?? "").trim();
2629
2637
  if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2630
2638
  requireProject(db, pid);
@@ -281,7 +281,15 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
281
281
  || fail "lock-refresh" "$LOCK_OUT2"
282
282
 
283
283
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
284
- PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
284
+ # The destination is the integration target resolved by the lifecycle, not a
285
+ # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
286
+ # stale clone of the same hardcoding the integrate fix removed).
287
+ PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" integration-target 2>&1)" \
288
+ || fail "integration-target" "$PUSH_TARGET"
289
+ if [ "$PUSH_TARGET" = "HEAD" ]; then
290
+ fail "push" "detached HEAD: publish-npm cannot push a version-bump commit from a detached checkout"
291
+ fi
292
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_TARGET" 2>&1)" \
285
293
  || fail "push" "$PUSH_OUT"
286
294
  echo "PUBLISH_PUSHED=1"
287
295
 
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic crew-home scaffold composer (2026-09-19, Room #19 D1).
3
+ //
4
+ // The old crew-init scaffold phase asked an agent to copy files AND report
5
+ // what it copied as JSON. The copying always worked; the JSON return didn't
6
+ // (room #19: the agent returned prose, the platform rejected it as non-JSON,
7
+ // init died on minute one). The step-back: copying files is deterministic
8
+ // work — no agent should be holding our data. This script does the copies,
9
+ // counts them, verifies every file on disk, and writes a receipt. The
10
+ // workflow consumes only this script's bytes; the agent's return is never
11
+ // parsed for data.
12
+ //
13
+ // CLI: node scaffold-crew.js --repo <crewRepoPath> --orch-dir <orchDir>
14
+ // Prints the receipt JSON {"created":n,"skipped":m} to stdout and writes it
15
+ // to <orchDir>/.scaffold-receipt.json (durable evidence). Exit 0 on success
16
+ // (idempotent: all-skipped is success). Exit 1 with the reason on stderr on
17
+ // any failure — including a missing or empty identities/personas/seed-workflows
18
+ // source (fail-closed: never stamp an empty 0/0 receipt). Exit 2 on usage
19
+ // errors. No wall-clock reads, no randomness.
20
+
21
+ const fs = require("node:fs");
22
+ const path = require("node:path");
23
+
24
+ function usageError(msg) {
25
+ process.stderr.write("scaffold-crew.js usage: --repo <crewRepoPath> --orch-dir <orchDir>\n" + msg + "\n");
26
+ process.exit(2);
27
+ }
28
+
29
+ const args = process.argv.slice(2);
30
+ let repo = null;
31
+ let orchDir = null;
32
+ for (let i = 0; i < args.length; i++) {
33
+ if (args[i] === "--repo" && i + 1 < args.length) repo = args[++i];
34
+ else if (args[i] === "--orch-dir" && i + 1 < args.length) orchDir = args[++i];
35
+ else usageError("unknown or incomplete argument: " + args[i]);
36
+ }
37
+ if (!repo) usageError("--repo is required");
38
+ if (!orchDir) usageError("--orch-dir is required");
39
+
40
+ function fail(msg) {
41
+ process.stderr.write("scaffold-crew: " + msg + "\n");
42
+ process.exit(1);
43
+ }
44
+
45
+ if (!fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) {
46
+ fail("crew repo not found or not a directory: " + repo);
47
+ }
48
+
49
+ // Fixed file sets. No judgment here: these globs are the scaffold.
50
+ //
51
+ // Fail-closed source guards (2026-09-19 REVIEW): an empty or missing source
52
+ // set used to sail through as a successful all-skipped scaffold (receipt
53
+ // 0/0). A crew with no identities, no personas, or no seed workflows is not
54
+ // a crew — refuse loudly (exit 1) before creating anything, instead of
55
+ // stamping an empty receipt. The feedback README stays optional.
56
+ function requireSource(dir, exts, label) {
57
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
58
+ fail("scaffold source missing: " + dir + " (" + label + " — refusing to scaffold an empty crew)");
59
+ }
60
+ const files = globFiles(dir, exts);
61
+ if (files.length === 0) {
62
+ fail("scaffold source empty: " + dir + " (no " + label + " files — refusing to scaffold an empty crew)");
63
+ }
64
+ return files;
65
+ }
66
+
67
+ const identityFiles = requireSource(path.join(repo, "identities"), [".md", ".png"], "identity");
68
+ const personaFiles = requireSource(path.join(repo, "personas"), [".md"], "persona");
69
+ const seedWorkflowFiles = requireSource(path.join(repo, "seed", "workflows"), [".md"], "seed workflow");
70
+
71
+ const dirs = ["identities", "personas", "workflows", "feedback"];
72
+ for (const d of dirs) {
73
+ fs.mkdirSync(path.join(orchDir, d), { recursive: true });
74
+ }
75
+
76
+ function globFiles(dir, exts) {
77
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
78
+ return fs.readdirSync(dir)
79
+ .filter((f) => exts.some((e) => f.endsWith(e)))
80
+ .sort()
81
+ .map((f) => path.join(dir, f));
82
+ }
83
+
84
+ const copies = [];
85
+ for (const f of identityFiles) {
86
+ copies.push([f, path.join(orchDir, "identities", path.basename(f))]);
87
+ }
88
+ for (const f of personaFiles) {
89
+ copies.push([f, path.join(orchDir, "personas", path.basename(f))]);
90
+ }
91
+ for (const f of seedWorkflowFiles) {
92
+ copies.push([f, path.join(orchDir, "workflows", path.basename(f))]);
93
+ }
94
+ const feedbackReadme = path.join(repo, "seed", "feedback", "README.md");
95
+ if (fs.existsSync(feedbackReadme)) {
96
+ copies.push([feedbackReadme, path.join(orchDir, "feedback", "README.md")]);
97
+ }
98
+
99
+ let created = 0;
100
+ let skipped = 0;
101
+ const expected = [];
102
+ for (const [src, dest] of copies) {
103
+ expected.push(dest);
104
+ if (fs.existsSync(dest)) {
105
+ skipped += 1; // no-clobber: existing files are never overwritten
106
+ continue;
107
+ }
108
+ try {
109
+ fs.copyFileSync(src, dest);
110
+ } catch (e) {
111
+ fail("copy failed: " + src + " -> " + dest + " (" + String(e.message || e) + ")");
112
+ }
113
+ created += 1;
114
+ }
115
+
116
+ // Disk verification is the decision rule: every expected file must exist.
117
+ const missing = expected.filter((f) => !fs.existsSync(f));
118
+ if (missing.length > 0) {
119
+ fail("incomplete scaffold: " + missing.length + " expected file(s) missing after copy: " + missing[0]);
120
+ }
121
+
122
+ const receipt = { created, skipped };
123
+ const receiptPath = path.join(orchDir, ".scaffold-receipt.json");
124
+ try {
125
+ fs.writeFileSync(receiptPath, JSON.stringify(receipt) + "\n");
126
+ } catch (e) {
127
+ fail("could not write receipt " + receiptPath + " (" + String(e.message || e) + ")");
128
+ }
129
+ process.stdout.write(JSON.stringify(receipt) + "\n");