muse-crew 0.7.20 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/guide.md CHANGED
@@ -17,7 +17,7 @@ This is the full setup and operations reference. If you're new, start with the [
17
17
  - A **release** — the first immutable snapshot of the crew's runtime code.
18
18
  - An **`.orchestration/` directory** in the crew home with identities, personas, workflow docs, and feedback conventions.
19
19
  - A **project registration** — the task service registered as its own first project.
20
- - **Cron jobs** — the scheduler state declared in `seed/crons.json`: a polling loop (every 15 minutes via Muse's scheduling, even when nobody's in the conversation). Owner is `space:<slug>`, so deleting the task service also removes the crons.
20
+ - **Cron jobs** — the scheduler state declared in `seed/crons.json`: a polling loop (every 15 minutes via Muse's scheduling, even when nobody's in the conversation). The scheduler identity is dashboard-independent — owner `cli:<instance-id>`, chosen once at first init — so deleting the dashboard artifact does not stop the crew. Removing a crew entirely is `crew-uninstall`'s job.
21
21
 
22
22
  The agent running in the main chat receives the dispatcher's claims and launches each task workflow. Workflows can't launch workflows, so this handoff is structural.
23
23
 
@@ -57,17 +57,21 @@ Dashboard arguments (optional as a pair — provide both or neither):
57
57
  - `dashboardSlug` — slug of the task service artifact (must already exist).
58
58
  - `dashboardRepoPath` — path to the dashboard's git repository (your local clone from the Prerequisites). Must be a real git repo inside the workspace. Init registers the dashboard project against this path and the crew's worktree lifecycle works from it — it is never the crew home, and init will never create it for you.
59
59
 
60
- Omit both dashboard arguments for a CLI-only install: init skips project registration (create projects later with `crew-api.js create-project`), and the polling cron is owned by `cli:<crew-home-basename>` instead of `space:<slug>`. The crew never requires a dashboard.
60
+ Omit both dashboard arguments for a CLI-only install: init skips project registration (create projects later with `crew-api.js create-project`). The scheduler identity is the same in both modes the crew never requires a dashboard, and attaching or removing one later never renames the crew's cron jobs.
61
61
 
62
62
  Optional arguments:
63
63
  - `dashboardName` — display name for the project registration (default: `"Muse Crew"`).
64
64
  - `crewName` — the human's chosen name for the crew. Stored as plain text at `$CREW_HOME/crew-name` and returned in the init summary. The setup conversation should always ask for one; init won't fail without it.
65
65
  - `cronIds` — manifest id → live id map for parallel instances on one account (default: `{}`). The manifest's ids are used as-is unless overridden, e.g. `{"crew-poll": "crew-poll-canary"}`.
66
- - `cronOwner` — explicit owner for the created cron jobs. Defaults to `space:<dashboardSlug>` in dashboard mode, `cli:<crew-home-basename>` in CLI-only mode.
66
+ - `cronOwner` — explicit owner for the created cron jobs. Defaults to `cli:<instance-id>`: the instance id is chosen at first init (the crew-home basename) and kept from `.cron-registry.json` on re-runs, so re-init never renames the crew's jobs.
67
+ - `autoUpdateCrew` — automatic crew upgrades on/off (default: `true`). Persists as the `auto_update_crew` config key; `false` turns the update watcher's crew check off.
68
+ - `autoUpdateDashboard` — automatic dashboard upgrades on/off (default: `true`). Persists as the `auto_update_dashboard` config key.
69
+ - `updateChannel` — `latest` (default) or `patch`: narrows crew upgrades to patch releases. Persists as the `update_channel` config key; any other value fails the init.
70
+ - Re-init semantics for all four policy values: an explicit input wins; otherwise the existing config value is kept; the default applies only on first init — a re-init that passes no inputs never silently resurrects a deliberate opt-out.
67
71
 
68
72
  ### What init creates
69
73
 
70
- Init runs four phases, each idempotent — re-running converges anything that drifted and skips what already matches:
74
+ Init runs five phases, each idempotent — re-running converges anything that drifted and skips what already matches:
71
75
 
72
76
  1. **Release** — copies `crew-release.sh` to `crewHome`, runs `init` to create the first release from the source directory. From a Git checkout, it extracts `workflows/` and `lib/` via `git archive` and uses the commit hash as the release identity. From an npm install, it copies the directories directly and uses the package version (e.g., `pkg-0.1.0`). Creates a release directory, sets the `current` symlink.
73
77
 
@@ -75,7 +79,9 @@ Init runs four phases, each idempotent — re-running converges anything that dr
75
79
 
76
80
  3. **Project registration** — registers the task service as a project in its own database via `createproject`, with `repo_path` set to the validated `dashboardRepoPath`. The dashboard becomes its own first project, so the crew can work on the dashboard itself. Re-running init is self-healing: a project already registered with the correct path is left alone, while one with a different path (e.g. from an older init) is repaired via `update-project`. Skipped entirely in CLI-only mode (no `dashboardSlug`) — create projects with `crew-api.js create-project` instead.
77
81
 
78
- 4. **Crons** — reads the manifest at `seed/crons.json` and makes the scheduler match it: missing jobs are created from the entry (title, enabled, mode, schedule, owner, timeout_secs, and the body built from the entry's template with `crewHome` and the dashboard slug substituted in); existing jobs are viewed and converged — drifted fields are updated, unchanged jobs are left alone. One exception: init never touches `enabled` on an existing job. `enabled` is a creation-time default only — a disabled job is a deliberate human decision, and re-init must not silently resurrect it. Jobs removed from the manifest are left alone; deletion is a human decision.
82
+ 4. **Crons** — reads the manifest at `seed/crons.json` and makes the scheduler match it: missing jobs are created from the entry (title, enabled, mode, schedule, owner, timeout_secs, and the body built from the entry's template with `crewHome` substituted in); existing jobs are viewed and converged — drifted fields are updated, unchanged jobs are left alone. The scheduler identity (`instanceId`, `cli:<instanceId>` owner) is chosen once at first init and never renamed by re-runs: the instance id comes from the existing `.cron-registry.json` when present, otherwise the crew-home basename. One exception: init never touches `enabled` on an existing job. `enabled` is a creation-time default only — a disabled job is a deliberate human decision, and re-init must not silently resurrect it. Jobs removed from the manifest are left alone; deletion is a human decision.
83
+
84
+ 5. **Update policy** — persists the automatic-update policy (`auto_update_crew`, `auto_update_dashboard`, `update_channel`) via the Crew API's `update-config`, with the re-init semantics above: explicit inputs win, existing values survive, defaults apply only on first init. Seeds `$CREW_HOME/.update-watch.json` as `{}` only when it does not exist (the watcher's idempotency record is never overwritten).
79
85
 
80
86
  ### Verification
81
87
 
@@ -93,7 +99,9 @@ After init completes, it returns a summary:
93
99
  "project": "registered",
94
100
  "crons": [
95
101
  { "id": "crew-poll", "action": "created", "updated_fields": [] }
96
- ]
102
+ ],
103
+ "autoUpdate": { "crew": true, "dashboard": true, "channel": "latest" },
104
+ "announcement": "Your crew and dashboard will update themselves automatically; say the word if you'd rather approve each one."
97
105
  }
98
106
  ```
99
107
 
@@ -188,7 +196,7 @@ Create a task using the `createtask` action (see the [API reference](../API.md))
188
196
  Required: `title`. Everything else has defaults.
189
197
 
190
198
  - `project` — which registered project this task belongs to.
191
- - `workflow` — `"standard"`, `"bugfix"`, `"chore"`, or `"docs"` (default: assigned at triage).
199
+ - `workflow` — `"standard"`, `"bugfix"`, `"chore"`, `"docs"`, or `"upgrade"` (default: assigned at triage).
192
200
  - `state` — `"todo"` (ready for dispatch), `"parked"` (held back), `"in_progress"`, or `"done"`.
193
201
  - `priority` — `"high"`, `"normal"`, or `"low"`.
194
202
  - `deps` — array of task IDs that must complete first (blocked until all deps are done).
@@ -228,6 +236,8 @@ Every 15 minutes, the `crew-poll` cron fires:
228
236
 
229
237
  7. **Task completes** — when all phases finish, the dispatcher marks the task `done` on the next tick.
230
238
 
239
+ Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch.
240
+
231
241
  ### Failure handling and retry
232
242
 
233
243
  A phase that fails doesn't fail the task — it fails the *session*, and the dispatcher retries the phase on the next tick. The retry state is explicit and dashboard-owned, never inferred from prose:
@@ -295,11 +305,40 @@ Configuration (per project, via the existing `getconfig` / `updateconfig` action
295
305
  | bugfix | Triage → Capture → Reproduce → Map → Build → Review → Integrate → Publish → QA |
296
306
  | chore | Triage → Capture → Map → Build → Review → Integrate → Publish |
297
307
  | docs | Triage → Write → Review |
308
+ | upgrade | Triage → Deploy → Verify |
309
+
310
+ ### Upgrading the crew
311
+
312
+ 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:
313
+
314
+ - `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.
315
+ - `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.
316
+
317
+ 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.
318
+
319
+ Four ways to start an upgrade:
320
+
321
+ 1. **Dashboard** — file an upgrade task against your crew's project.
322
+ 2. **Manual crew** — create a task with `workflow: "upgrade"` in any project, with or without a `source:` line.
323
+ 3. **Self** — the crew can file its own upgrade task: any workflow's work agent can create one once it knows a newer release exists (release discovery is a future capability — `lib/update-watch.js`).
324
+ 4. **External npm** — file an upgrade task with `source: npm@x.y.z` to install a published version from the registry.
325
+
326
+ Fail-closed rules: an upgrade never mutates the crew repo (repo source always means the repo's current HEAD, never a checkout or pull), never publishes to npm, never touches the scheduler or retries, and never rolls back automatically. A failed deploy parks the task with the exact deploy output; `crew-release.sh rollback` is the human recovery path. If the crew is already on the target release, the task completes as an idempotent no-op.
298
327
 
299
328
  ### The visual verdict
300
329
 
301
330
  For tasks that change anything rendered and visible in the project's artifact ("experiential" tasks), QA owns the visual verdict. Capture runs right after Triage to collect pre-change baseline evidence (parent-driven — see `docs/visual-verdict.md`); Map cannot be written without it; final QA covers mechanical checks only and the task does not complete until a `visual_verdict: PASS` is recorded. A FAIL reworks at Build within the shared budget; a `rendering impossible:` FAIL parks for human attention. Hazel judges against the artist-eye rubric (alignment, spacing, hierarchy, composition, balance, finish, taste) in `identities/hazel.md`, from rendered evidence only — never from prose descriptions.
302
331
 
332
+ ### Reproduce at the bug's layer
333
+
334
+ Bugfix tasks reproduce at the layer where the bug lives — not always in the browser. During Triage, Sage classifies the bug's layer with a machine-read marker line (`layer: artifact`, `layer: engine`, or `layer: docs`):
335
+
336
+ - **artifact** — user-facing behavior of the rendered artifact (something a user sees or clicks). Reproduced with the experiential see-act browser loop, as today.
337
+ - **engine** — the crew's own machinery: workflows, lib scripts, shell scripts, tests, scheduler. Reproduced deterministically with shell commands in the repo checkout — run the failing script, re-run the failing test suite (`bash tests/run.sh`), query crew state via the API.
338
+ - **docs** — a documentation gap or error. Reproduced by reading the file and confirming the gap is actually there.
339
+
340
+ The workflow dispatches the Reproduce strategy mechanically on the classification. A garbled or missing marker degrades to `artifact` (the old behavior) — a task is never parked on it. The experiential loop is forbidden for engine/docs tasks: if the reproducer drives the browser anyway, the closeout fails the phase loudly for retry, so wrong-layer verdicts never reach Map.
341
+
303
342
  ### Publish content verification
304
343
 
305
344
  The artifact builder's `applied` report is derived from the diff the workflow carries to it, so comparing the report to the diff is circular — canary run 8 (2026-09-11) stamped provenance on a hollow build and every phase went green. The workflow therefore never stamps provenance itself: after the build lands it parks with `publish: verification-requested <commit> (build <agent_id|agent_id unobserved>)`. "Landed" requires positive evidence (canary 2026-09-15, task `1d692d91`): the build poll must have positively observed our build — a running build with the receipt `agent_id`, or a completed-build record matching it. Absence of a running build is not evidence our build ran; an unobserved "done" is an unknown outcome, parked fail-closed with an append-only `unknown` ledger entry — never parked as verification-requested. The parent protocol owns the independent content confirmation (parent-driven — see `docs/publish-verification.md`); the park message records the observed builder build identifier as `(build <agent_id|agent_id unobserved>)`, and the parent correlates the read-back's live build agent_id against it — a mismatch logs `publish: build-mismatch <commit> …`, stays parked, and is never stamped (parent-driven — see `docs/publish-verification.md` step 4b). The independent read-back step is currently unavailable: `artifact_inspect` was removed by the platform (2026-09-14) and no agent-callable replacement exists (`artifact.inspect` is malfunction diagnosis, not a read-back tool), so the parent cannot confirm content independently and tasks stay parked at verification-requested until a read-back path exists. QA's provenance check then enforces the stamp mechanically, so an unverified publish fails loudly in QA instead of passing silently.
@@ -418,6 +457,139 @@ Each release is identified by its source: a full Git commit hash for Git checkou
418
457
  crew-release.sh current
419
458
  ```
420
459
 
460
+ ### Automatic updates
461
+
462
+ A daily deterministic script (`lib/update-watch.js`, run by the
463
+ `crew-update-watch` cron) watches for new releases and files upgrade tasks
464
+ through the normal loop — it never deploys, never touches the artifact,
465
+ never mutates config. The loop applies.
466
+
467
+ - **Crew:** the public npm registry (`npm view muse-crew version`, pinned to
468
+ `https://registry.npmjs.org/`) vs the live release (`crew-release.sh
469
+ current`). If a newer version is published and the policy/channel allow,
470
+ the watcher files a `workflow: "upgrade"` task with `source: npm@<version>`.
471
+ Non-npm releases (git-hash dev builds) are skipped — they cannot be
472
+ compared to registry versions.
473
+ - **Dashboard:** `git ls-remote origin HEAD` on the first
474
+ `deploy_type=artifact` project vs the recorded sha in
475
+ `$CREW_HOME/.update-watch.json`. If the origin has a new ref, the watcher
476
+ files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade
477
+ journey (fetch, ancestor check, review, let Publish carry the artifact update).
478
+
479
+ #### Dashboard compatibility anchor (.crew-version)
480
+
481
+ A dashboard release declares the crew version it needs in a `.crew-version`
482
+ file at the repo root — one line, an exact version (`0.7.21`, strict semver,
483
+ no ranges). Bump it in the same commit as any change that needs a new crew.
484
+ The file is added on the dashboard-repo side at release time; the watcher
485
+ only honors it.
486
+
487
+ The watcher reads the declaration at the filed ref (`git fetch origin`, then
488
+ `git show <sha>:.crew-version` — never the local working tree, which still
489
+ sits at the recorded sha while a newer ref is being evaluated) and treats
490
+ (dashboard ref, crew version) as one unit, ordered dashboard-led:
491
+
492
+ - Declared crew newer than the running release: the crew upgrade task
493
+ (`workflow: "upgrade"`, `source: npm@<declared>`, `filed_by:
494
+ update-watch`) is filed first, and the dashboard task notes that it follows
495
+ the crew upgrade (old→new dashboard commits plus the required crew
496
+ version). The declaration is a requirement, not a preference: it bypasses
497
+ `update_channel`, but not the `auto_update_crew=false` opt-out — with the
498
+ opt-out, only the dashboard task is filed, noting the required version for
499
+ a human.
500
+ If the crew upgrade task cannot be filed (no `muse-crew` project
501
+ registered, or `create-task` fails): the whole dashboard leg is held —
502
+ nothing is filed, the old dashboard sha stays in the state file, and the
503
+ ref is re-evaluated on the next run, so the (dashboard ref, crew version)
504
+ unit is never ordered half-way.
505
+ - Declared crew equal to the running release: the dashboard task is filed as
506
+ today, no crew task.
507
+ - Declared crew older than the running release: the dashboard leg is skipped
508
+ and logged — a downgrade is a human decision, never automatic. State is
509
+ left untouched.
510
+ - Missing, unreadable, or invalid file: silent with a log line, and the
511
+ dashboard leg proceeds as today. Updates are never blocked on a missing
512
+ declaration.
513
+
514
+ **Policy** (crew config, set at setup, changeable later via the Crew API's
515
+ `update-config`):
516
+
517
+ - `auto_update_crew` — default `true`. `false` turns the crew check off.
518
+ - `auto_update_dashboard` — default `true`. `false` turns the dashboard check off.
519
+ - `update_channel` — `latest` (default) or `patch` (crew upgrades only for
520
+ patch releases). Any other value is logged and treated as `latest`.
521
+
522
+ Setup announces the default with a one-word opt-out: "Your crew and
523
+ dashboard will update themselves automatically; say the word if you'd
524
+ rather approve each one." Opting out is a re-run with
525
+ `autoUpdateCrew: false` / `autoUpdateDashboard: false`; a re-init that
526
+ passes no inputs never resurrects a deliberate opt-out.
527
+
528
+ **Trust anchors:**
529
+
530
+ - Publisher + registry trust = install trust. The watcher adds no new
531
+ trust: it watches the same public registry the install came from.
532
+ - The upgrade workflow's Verify phase is the real gate — nothing deploys
533
+ without it.
534
+ - Fail-closed + retained rollback: a bad upgrade parks; `crew-release.sh
535
+ rollback` is the human recovery path.
536
+ - Capability split: the watcher reaches the network but can never deploy —
537
+ it only files tasks. The deployer never reaches the network beyond the
538
+ package install the task explicitly names.
539
+
540
+ **Idempotency:** `$CREW_HOME/.update-watch.json` records the last filed crew
541
+ version and per-project dashboard sha (recorded at file time, so a parked
542
+ upgrade stays visible in the loop instead of being silently re-filed).
543
+ Check failures (npm unreachable, `ls-remote` auth friction) are silent with
544
+ a log line in `$CREW_HOME/update-watch.log` — the script always exits 0 so
545
+ a failed check never breaks the loop.
546
+
547
+ ## npm releases
548
+
549
+ The npm Publish phase (`lib/publish-npm.sh`) releases the crew to the
550
+ registry as `muse-crew@<target-version>`: it writes the workflow-computed
551
+ target version into `package.json`, commits it, packs a tarball, publishes
552
+ via the publish helper, verifies against the registry, pushes, and
553
+ finalizes.
554
+
555
+ ### What the publish path needs
556
+
557
+ Two things, both machine-local:
558
+
559
+ 1. **The publish helper** — `NPM_PUBLISH_PY`, defaulting to
560
+ `$HOME/workspace/skills/npm/bin/npm-publish.py`. The script invokes it
561
+ as `python3 <helper> <tarball>`; it only needs to be a readable file
562
+ (the executable bit is irrelevant).
563
+ 2. **The `custom.npm` credential** — the npm publish token in the secure
564
+ vault, under exactly that name. The helper probes it with
565
+ `python3 <helper> --check`, which reports presence/absence only and
566
+ never prints credential material.
567
+
568
+ ### How to configure it
569
+
570
+ - Point `NPM_PUBLISH_PY` at your own helper (any script that takes a
571
+ tarball path, and optionally `--check` for the credential probe).
572
+ - Store the publish token as `custom.npm` in the secure vault.
573
+
574
+ A helper that predates `--check` still works: the probe's failure is
575
+ treated as "unknown", not "absent", and the publish runs with today's
576
+ fail-closed behavior.
577
+
578
+ ### What the skip means
579
+
580
+ When the helper or the credential is absent, the preflight (which runs
581
+ before any mutation — before the version write, the commit, the tarball)
582
+ skips gracefully and the Publish agent reports:
583
+
584
+ ```
585
+ PUBLISH_SKIPPED=no-npm-publish
586
+ ```
587
+
588
+ This is a designed outcome, not a failure. The merge stands, the version
589
+ was not cut, nothing was shipped — and the task completes normally. There
590
+ is no retry and nothing to park: a missing credential is configuration,
591
+ not a transient error, so one probe and one skip is the whole story.
592
+
421
593
  ## Current limitations
422
594
 
423
595
  1. **No global kill switch.** Only per-project pause/resume exists. Setting global `config.simultaneity` to 0 does not override projects with explicit nonzero values. A global stop requires pausing each project individually.
package/lib/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ 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>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/pathnever reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
10
+ - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
11
11
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
12
12
  - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
13
13
  - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
@@ -25,3 +25,4 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
25
25
  - `readback-disk.js` — deterministic publish content sensor (2026-09-16): reads the on-disk tree the artifact is built/served from and emits the machine-readable findings block (`FILE:`/`ADDED:`/`REMOVED:`/`END_FILE`) that `verify-publish.js` judges. The primary sensor — the LLM-inspector path (`build-readback-request.js`) is manual-fallback only since `artifact_inspect` was removed by the platform 2026-09-14.
26
26
  - `build-readback-request.js` — builds the LLM-inspector read-back `verbatim_request` from the merge commit's diff (2026-09-14): carries the merged diff as the expected change and asks for an independent read of the artifact's actual source. Retained as the manual fallback; the deterministic `readback-disk.js` is the primary sensor.
27
27
  - `verify-publish.js` — mechanical publish verification judge (2026-09-14/16): certifies the read-back findings block against `git diff` (strict `FILE:`/`ADDED:`/`REMOVED:`/`END_FILE` parsing, every added line PRESENT / every removed line ABSENT, HEAD==commit supersession check) and only then stamps provenance. Binary files, mode-only changes, and fully-colliding added hunks fail closed as `unverifiable-content` (2026-09-16, critic findings 1/5) — they can never vacuously stamp. Content-mismatch, unreadable-result, superseded, and stamp failures exit 1 with `publish: verification-failed` and no stamp. Collision exemption (2026-09-15): a removed line occurring in the old tree strictly more times than the diff removes is exempt from ABSENT (zero discriminating power); computed from `git show <base>:<path>`, never the working tree.
28
+ - `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`.
@@ -2,7 +2,8 @@
2
2
  // build-registry.js — deterministic extraction of the workflow step registry.
3
3
  //
4
4
  // The workflow files (workflows/standard.js, workflows/bugfix.js,
5
- // workflows/chore.js, workflows/docs.js) are the single source of truth.
5
+ // workflows/chore.js, workflows/docs.js, workflows/upgrade.js) are the
6
+ // single source of truth.
6
7
  // This script generates workflows/registry.json at release time (invoked by
7
8
  // crew-release.sh cmd_deploy). The JSON is a build artifact and must never be
8
9
  // hand-edited — publish a new release instead.
@@ -14,7 +15,7 @@ const fs = require("fs");
14
15
  const path = require("path");
15
16
  const vm = require("vm");
16
17
 
17
- const WORKFLOWS = ["standard", "bugfix", "chore", "docs"];
18
+ const WORKFLOWS = ["standard", "bugfix", "chore", "docs", "upgrade"];
18
19
 
19
20
  function fail(msg) {
20
21
  process.stderr.write("build-registry: " + msg + "\n");
@@ -27,6 +27,10 @@
27
27
  # When no merge lock is held (empty-diff Integrate), the publish path is
28
28
  # skipped gracefully: PUBLISH_SKIPPED=no-lock-held is printed, no version
29
29
  # is written or published, and PUBLISH_COMPLETE is omitted.
30
+ # When npm publish is not configured on this machine (publish helper or
31
+ # credential absent), the preflight skips gracefully before any mutation:
32
+ # PUBLISH_SKIPPED=no-npm-publish is printed, no version is written or
33
+ # published, and PUBLISH_COMPLETE is omitted (see docs/guide.md#npm-releases).
30
34
 
31
35
  set -euo pipefail
32
36
 
@@ -49,6 +53,35 @@ echo "PUBLISH_TARGET=$TARGET_VERSION"
49
53
  git -C "$REPO_PATH" rev-parse --git-dir >/dev/null 2>&1 \
50
54
  || fail "env" "REPO_PATH is not a git checkout: $REPO_PATH"
51
55
 
56
+ # PREFLIGHT-ANCHOR: publish capability preflight (runs before ANY mutation).
57
+ # publish-npm.sh assumed the author-machine publish helper path and failed
58
+ # cryptically AFTER the version-bump commit when npm publish was not
59
+ # configured, leaving a release commit for a version that never shipped.
60
+ # Probe first: when the helper or the publish credential is absent, skip
61
+ # cleanly with PUBLISH_SKIPPED=no-npm-publish — no commit, no tarball,
62
+ # nothing mutated. Checks -f/-r only: the helper is invoked as
63
+ # `python3 <path>`, so the executable bit is irrelevant (requiring it would
64
+ # false-skip on files that are rw-rw----). An old helper that does not know
65
+ # --check (nonzero exit without a CREDENTIAL_* marker) is "unknown", not
66
+ # "absent": fall through to today's fail-closed behavior at step 11.
67
+ # Placed before DRY_RUN on purpose: a dry run on an unconfigured machine
68
+ # honestly reports "would skip". No wall-clock, no randomness, no new env
69
+ # vars — the determinism scan stays clean. Never prints credential material.
70
+ if ! [ -f "$NPM_PUBLISH_PY" ] || ! [ -r "$NPM_PUBLISH_PY" ]; then
71
+ echo "PUBLISH_SKIPPED=no-npm-publish"
72
+ echo "npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
73
+ exit 0
74
+ fi
75
+ PREFLIGHT_CHECK_CODE=0
76
+ PREFLIGHT_CHECK_OUT="$(python3 "$NPM_PUBLISH_PY" --check 2>&1)" || PREFLIGHT_CHECK_CODE=$?
77
+ if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
78
+ && printf '%s' "$PREFLIGHT_CHECK_OUT" | grep -q 'CREDENTIAL_MISSING=custom.npm'; then
79
+ echo "PUBLISH_SKIPPED=no-npm-publish"
80
+ echo "npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
81
+ exit 0
82
+ fi
83
+ # PREFLIGHT-END
84
+
52
85
  # 3. Dry run: markers only, no lock, no deploy, no mutation.
53
86
  if [ "${DRY_RUN:-0}" = "1" ]; then
54
87
  echo "PUBLISH_DRY_RUN=1"
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env bash
2
+ # test-publish-preflight.sh — regression tests for the npm publish
3
+ # capability preflight (bugfix 2026-09-17): publish-npm.sh assumed the
4
+ # author-machine publish helper path and failed cryptically AFTER the
5
+ # version-bump release commit when npm publish was not configured.
6
+ #
7
+ # Covers:
8
+ # - structural: the PREFLIGHT block exists in lib/publish-npm.sh
9
+ # (extracted by anchor), sits after env validation and before the
10
+ # DRY_RUN block, checks the helper with -f/-r only (never -x — the
11
+ # helper is invoked as `python3 <path>`), and reads no wall clock or
12
+ # randomness.
13
+ # - full-script runs against a scratch repo:
14
+ # * NPM_PUBLISH_PY missing -> PUBLISH_SKIPPED=no-npm-publish + the
15
+ # actionable line, exit 0, HEAD unchanged, status clean, no tarball
16
+ # * fake helper whose --check reports CREDENTIAL_MISSING=custom.npm
17
+ # (exit 1) -> same skip outcome, exit 0, repo untouched
18
+ # * fake helper whose --check reports CREDENTIAL_OK=custom.npm
19
+ # (exit 0) with DRY_RUN=1 -> preflight passes through to
20
+ # PUBLISH_DRY_RUN=1 (asserts pass-through, not the mutation)
21
+ # * old helper that does not know --check (usage to stderr, exit 1,
22
+ # no CREDENTIAL markers) with DRY_RUN=1 -> no skip (fall-through
23
+ # preserved: unknown is not absent)
24
+ # - --check probe unit test against the real helper: on the author
25
+ # machine this expects exit 0 and exactly CREDENTIAL_OK=custom.npm.
26
+ # NOTE: this case depends on the `custom.npm` credential existing in
27
+ # the secure vault — on a machine without it the probe correctly
28
+ # reports CREDENTIAL_MISSING=custom.npm (exit 1) and this case fails.
29
+ # That is expected outside the author machine, not a regression.
30
+ # - bash -n syntax check on publish-npm.sh
31
+ #
32
+ # Anchors: `# PREFLIGHT-ANCHOR` ... `# PREFLIGHT-END` in lib/publish-npm.sh.
33
+
34
+ set -uo pipefail
35
+
36
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37
+ PUBLISH="$SCRIPT_DIR/publish-npm.sh"
38
+ REAL_HELPER="$HOME/workspace/skills/npm/bin/npm-publish.py"
39
+
40
+ pass=0
41
+ fail_count=0
42
+ ok() { echo "PASS: $1"; pass=$((pass + 1)); }
43
+ no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
44
+
45
+ TMPBASE="$(mktemp -d)"
46
+ trap 'rm -rf "$TMPBASE"' EXIT
47
+
48
+ # --- Scratch repo (Publish's REPO_PATH must be a git checkout) ---
49
+ SCRATCH="$TMPBASE/repo"
50
+ git init -q -b main "$SCRATCH"
51
+ git -C "$SCRATCH" config user.email "test@crew"
52
+ git -C "$SCRATCH" config user.name "crew-test"
53
+ echo x > "$SCRATCH/f.txt"
54
+ git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
55
+
56
+ # --- Fake helpers ---
57
+ cat > "$TMPBASE/helper-ok.py" <<'EOF'
58
+ #!/usr/bin/env python3
59
+ import sys
60
+ if len(sys.argv) == 2 and sys.argv[1] == "--check":
61
+ print("CREDENTIAL_OK=custom.npm")
62
+ sys.exit(0)
63
+ sys.exit(1)
64
+ EOF
65
+ cat > "$TMPBASE/helper-missing-cred.py" <<'EOF'
66
+ #!/usr/bin/env python3
67
+ import sys
68
+ if len(sys.argv) == 2 and sys.argv[1] == "--check":
69
+ print("CREDENTIAL_MISSING=custom.npm")
70
+ sys.exit(1)
71
+ sys.exit(1)
72
+ EOF
73
+ # An old helper that predates --check: usage to stderr, exit 1, no markers.
74
+ cat > "$TMPBASE/helper-old.py" <<'EOF'
75
+ #!/usr/bin/env python3
76
+ import sys
77
+ print("Usage: helper-old.py <tarball-path>", file=sys.stderr)
78
+ sys.exit(1)
79
+ EOF
80
+
81
+ ACTIONABLE="npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
82
+
83
+ # --- Structural: extract the shipped preflight block by anchor ---
84
+ BLOCK="$TMPBASE/preflight.sh"
85
+ sed -n '/# PREFLIGHT-ANCHOR/,/# PREFLIGHT-END/p' "$PUBLISH" > "$BLOCK"
86
+ if grep -q 'PUBLISH_SKIPPED=no-npm-publish' "$BLOCK" \
87
+ && grep -q 'docs/guide.md#npm-releases' "$BLOCK" \
88
+ && grep -q 'exit 0' "$BLOCK"; then
89
+ ok "extracted PREFLIGHT block from publish-npm.sh"
90
+ else
91
+ no "PREFLIGHT extraction failed (anchors missing)"
92
+ fi
93
+
94
+ # The helper is invoked as `python3 <path>`: -f/-r, never -x.
95
+ if grep -q '\[ -f "\$NPM_PUBLISH_PY" \]' "$BLOCK" \
96
+ && grep -q '\[ -r "\$NPM_PUBLISH_PY" \]' "$BLOCK" \
97
+ && ! grep -q '\[ -x "\$NPM_PUBLISH_PY" \]' "$BLOCK"; then
98
+ ok "preflight checks -f/-r on the helper, never -x"
99
+ else
100
+ no "preflight helper checks wrong (want -f/-r, no -x)"
101
+ fi
102
+
103
+ # No wall-clock or randomness in the preflight block.
104
+ if ! grep -Eq '\$\(date|date \+|\$RANDOM' "$BLOCK"; then
105
+ ok "preflight block reads no wall clock and no randomness"
106
+ else
107
+ no "preflight block contains clock/randomness"
108
+ fi
109
+
110
+ # Placement: after env validation, before the DRY_RUN block.
111
+ VALIDATE_LINE="$(grep -n 'rev-parse --git-dir' "$PUBLISH" | head -1 | cut -d: -f1)"
112
+ ANCHOR_LINE="$(grep -n '# PREFLIGHT-ANCHOR' "$PUBLISH" | head -1 | cut -d: -f1)"
113
+ END_LINE="$(grep -n '# PREFLIGHT-END' "$PUBLISH" | head -1 | cut -d: -f1)"
114
+ DRYRUN_LINE="$(grep -n 'DRY_RUN:-0}' "$PUBLISH" | head -1 | cut -d: -f1)"
115
+ if [ -n "$VALIDATE_LINE" ] && [ -n "$ANCHOR_LINE" ] && [ -n "$END_LINE" ] \
116
+ && [ -n "$DRYRUN_LINE" ] \
117
+ && [ "$VALIDATE_LINE" -lt "$ANCHOR_LINE" ] \
118
+ && [ "$END_LINE" -lt "$DRYRUN_LINE" ]; then
119
+ ok "PREFLIGHT block sits after env validation, before DRY_RUN"
120
+ else
121
+ no "PREFLIGHT placement wrong (validate=$VALIDATE_LINE anchor=$ANCHOR_LINE end=$END_LINE dryrun=$DRYRUN_LINE)"
122
+ fi
123
+
124
+ # Runner: full publish-npm.sh against the scratch repo. The preflight exits
125
+ # before any mutation, so the repo needs no package.json.
126
+ run_full() { # run_full <helper_path> [extra_env...]
127
+ local helper="$1"; shift
128
+ local run="$TMPBASE/run-$RANDOM"
129
+ mkdir -p "$run"
130
+ (
131
+ export TASK_ID="preflight-test" REPO_PATH="$SCRATCH" PKG="muse-crew" TARGET_VERSION="9.9.9"
132
+ export NPM_PUBLISH_PY="$helper"
133
+ # A bare `export` (empty "$@") would dump the environment to stdout and
134
+ # corrupt the captured run path — only export when extras are given.
135
+ if [ $# -gt 0 ]; then export "$@"; fi
136
+ bash "$PUBLISH" > "$run/out" 2>&1
137
+ )
138
+ local code=$?
139
+ echo "$code" > "$run/code"
140
+ echo "$run"
141
+ }
142
+
143
+ repo_state() { # prints "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
144
+ local head status tgz
145
+ head="$(git -C "$SCRATCH" rev-parse HEAD)"
146
+ status="$(git -C "$SCRATCH" status --porcelain | wc -l)"
147
+ tgz="$(find "$SCRATCH" -maxdepth 1 -name '*.tgz' | wc -l)"
148
+ echo "HEAD=$head STATUS=$status TGZ=$tgz"
149
+ }
150
+
151
+ # --- Case 1: helper missing -> clean skip, exit 0, repo untouched ---
152
+ BEFORE="$(repo_state)"
153
+ RUN="$(run_full /nonexistent/path.py)"
154
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state)"
155
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-npm-publish"* ]] \
156
+ && [[ "$OUT" == *"$ACTIONABLE"* ]] \
157
+ && [[ "$OUT" != *"PUBLISH_FAILED"* ]] && [[ "$OUT" != *"PUBLISH_COMPLETE"* ]] \
158
+ && [ "$BEFORE" = "$AFTER" ]; then
159
+ ok "helper missing: skips with marker + actionable line, exit 0, repo untouched"
160
+ else
161
+ no "helper missing: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
162
+ fi
163
+
164
+ # --- Case 2: helper present, credential missing -> same skip, repo untouched ---
165
+ BEFORE="$(repo_state)"
166
+ RUN="$(run_full "$TMPBASE/helper-missing-cred.py")"
167
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state)"
168
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-npm-publish"* ]] \
169
+ && [[ "$OUT" == *"$ACTIONABLE"* ]] \
170
+ && [[ "$OUT" != *"PUBLISH_FAILED"* ]] && [[ "$OUT" != *"PUBLISH_COMPLETE"* ]] \
171
+ && [ "$BEFORE" = "$AFTER" ]; then
172
+ ok "credential missing: skips with marker + actionable line, exit 0, repo untouched"
173
+ else
174
+ no "credential missing: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
175
+ fi
176
+
177
+ # --- Case 3: credential present + DRY_RUN=1 -> preflight passes through ---
178
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1)"
179
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
180
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
181
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]]; then
182
+ ok "credential present: preflight passes through to PUBLISH_DRY_RUN=1"
183
+ else
184
+ no "credential present: code=$CODE out='$OUT'"
185
+ fi
186
+
187
+ # --- Case 4: old helper without --check -> no skip (fall-through) ---
188
+ RUN="$(run_full "$TMPBASE/helper-old.py" DRY_RUN=1)"
189
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
190
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
191
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]]; then
192
+ ok "old helper (no --check): unknown, not absent — falls through, no skip"
193
+ else
194
+ no "old helper: code=$CODE out='$OUT'"
195
+ fi
196
+
197
+ # --- Case 5: real helper --check probe (author machine: credential present) ---
198
+ CHECK_OUT="$(python3 "$REAL_HELPER" --check 2>&1)"
199
+ CHECK_CODE=$?
200
+ if [ "$CHECK_CODE" -eq 0 ] && [ "$CHECK_OUT" = "CREDENTIAL_OK=custom.npm" ]; then
201
+ ok "real helper --check: CREDENTIAL_OK=custom.npm, exit 0, nothing else printed"
202
+ else
203
+ no "real helper --check: code=$CHECK_CODE out='$CHECK_OUT' (expected CREDENTIAL_OK=custom.npm — this case is credential-dependent; see header)"
204
+ fi
205
+
206
+ bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
207
+
208
+ echo ""
209
+ echo "$pass passed, $fail_count failed"
210
+ exit $((fail_count > 0))
@@ -147,7 +147,57 @@ echo "$out" | grep -q '^MERGED: ' || fail "integrate-test: no MERGED line (no re
147
147
  echo "$out" | grep -q '^NO_REMOTE: ' || fail "integrate-test: no NO_REMOTE line: $out"
148
148
  "$CREW_LIB/merge-lock.sh" release "int2" >/dev/null || fail "integrate-test: lock release (no remote) failed"
149
149
 
150
- rm -rf "$T2" "$T3"
150
+ # Prefix resolution (task 4e1a1bba, 2026-09-17): a Build agent created the
151
+ # branch with raw git as task/<8-char id>, bypassing prepare, so the
152
+ # registry is empty. resolve-branch must find it by id-prefix enumeration;
153
+ # inspect/integrate/cleanup must operate on the real branch, never on a
154
+ # reconstructed task/<full-id>.
155
+ T4=/tmp/crew-git-prefix-test
156
+ rm -rf "$T4"
157
+ mkdir -p "$T4"
158
+ git init -q -b main "$T4/repo"
159
+ git -C "$T4/repo" config user.email test@test.t
160
+ git -C "$T4/repo" config user.name test
161
+ echo base > "$T4/repo/f.txt"
162
+ echo ".worktrees/" > "$T4/repo/.gitignore"
163
+ git -C "$T4/repo" add -A
164
+ git -C "$T4/repo" commit -qm base
165
+ export CREW_REPO="$T4/repo"
166
+
167
+ fullid="51aaa9fe-9cb5-4a4f-acfc-72092fcf0899"
168
+ shortid="51aaa9fe"
169
+ # Raw-git branch creation, like the Build agent: no registry entry.
170
+ git -C "$T4/repo" branch "task/$shortid"
171
+ [ -f "$T4/repo/.worktrees/.registry/$fullid" ] && fail "prefix-test: registry should be empty for this fixture"
172
+ out=$(bash "$LIFECYCLE" resolve-branch "$fullid") || fail "prefix-test: resolve-branch failed: $out"
173
+ [ "$out" = "task/$shortid" ] || fail "prefix-test: resolve-branch returned '$out', expected task/$shortid"
174
+
175
+ # inspect operates on the real branch (no "branch ... does not exist")
176
+ out=$(bash "$LIFECYCLE" inspect "$fullid") || fail "prefix-test: inspect failed on abbreviated branch: $out"
177
+ echo "$out" | grep -q "=== Branch: task/$shortid ===" || fail "prefix-test: inspect used the wrong branch"
178
+
179
+ # verify-merge vacuous-pass on the merged-prefix branch tip == main
180
+ out=$(bash "$LIFECYCLE" verify-merge "$fullid") || fail "prefix-test: verify-merge failed on abbreviated branch: $out"
181
+
182
+ # cleanup deletes the real abbreviated branch, not a reconstructed one
183
+ out=$(bash "$LIFECYCLE" cleanup "$fullid") || fail "prefix-test: cleanup failed: $out"
184
+ git -C "$T4/repo" rev-parse --verify "task/$shortid" >/dev/null 2>&1 && fail "prefix-test: cleanup left the abbreviated branch behind"
185
+
186
+ # Ambiguous prefixes fail closed (exit non-zero), never guess
187
+ fullid2="deadbeef-1111-2222-3333-444455556666"
188
+ git -C "$T4/repo" branch "task/deadbeef"
189
+ git -C "$T4/repo" branch "task/deadbeef-1111"
190
+ if bash "$LIFECYCLE" resolve-branch "$fullid2" >/dev/null 2>&1; then
191
+ fail "prefix-test: resolve-branch guessed on ambiguous prefix"
192
+ fi
193
+ git -C "$T4/repo" branch -D "task/deadbeef" "task/deadbeef-1111" >/dev/null
194
+
195
+ # No branch at all: resolve-branch falls back to the canonical name so
196
+ # callers keep their "branch X does not exist" failure
197
+ out=$(bash "$LIFECYCLE" resolve-branch "$fullid") || fail "prefix-test: resolve-branch failed with no branch present"
198
+ [ "$out" = "task/$fullid" ] || fail "prefix-test: resolve-branch returned '$out', expected canonical fallback task/$fullid"
199
+
200
+ rm -rf "$T2" "$T3" "$T4"
151
201
  export CREW_REPO="$T"
152
202
  rm -rf "$T"
153
203
  echo "worktree-backend: OK"