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 +1 -1
- package/docs/decisions/publish-path.md +6 -6
- package/docs/decisions/workflow-core.md +1 -1
- package/docs/guide.md +3 -3
- package/lib/AGENTS.md +7 -3
- package/lib/advance-publish-base.js +74 -28
- package/lib/crew-api.js +8 -0
- package/lib/publish-npm.sh +9 -1
- package/lib/scaffold-crew.js +129 -0
- package/lib/test-detached-integrate.sh +340 -0
- package/lib/worktree-lifecycle.sh +338 -94
- package/package.json +1 -1
- package/workflows/bugfix.js +48 -58
- package/workflows/chore.js +45 -55
- package/workflows/crew-dispatch.js +43 -8
- package/workflows/crew-init.js +333 -78
- package/workflows/crew-uninstall.js +57 -10
- package/workflows/standard.js +46 -52
- package/workflows/upgrade.js +27 -13
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
|
|
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
|
|
1136
|
-
// vacuously — the tip is then an ancestor of
|
|
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
|
|
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
|
|
1159
|
-
// the deliverable sat on
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
14
|
-
//
|
|
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> --
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!crewHome || !repo || !
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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);
|
package/lib/publish-npm.sh
CHANGED
|
@@ -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
|
-
|
|
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");
|