@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.0-beta.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.
Files changed (34) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +43 -0
  4. package/template/_claude/hooks/session-start.mjs +10 -0
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  15. package/template/_claude/scripts/task-pr.mjs +316 -72
  16. package/template/_claude/scripts/task-worktree.mjs +9 -1
  17. package/template/_claude/scripts/template-merge.mjs +21 -4
  18. package/template/_claude/scripts/template-modifications.mjs +152 -0
  19. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  20. package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
  21. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  22. package/template/_claude/skills/braindump/SKILL.md +1 -1
  23. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  24. package/template/_claude/skills/handoff/SKILL.md +1 -1
  25. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  26. package/template/_claude/skills/release/SKILL.md +20 -6
  27. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  28. package/template/_claude/skills/start-work/SKILL.md +23 -5
  29. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  30. package/template/workspace.json.tmpl +2 -1
  31. package/lib/init.test.mjs +0 -75
  32. package/lib/payload.test.mjs +0 -210
  33. package/lib/scaffold.test.mjs +0 -79
  34. package/lib/upgrade.test.mjs +0 -547
@@ -26,7 +26,7 @@ When called within an active work session (the active-session pointer at `.claud
26
26
  git commit -m "braindump: update {session-name} tracker"
27
27
  ```
28
28
 
29
- Under the task model — `workspace.sessionModel` is `"task"` in `workspace.json` AND the SessionStart hook injected a `Chat record:` line (`{chat}` is its name):
29
+ Under the task model — `workspace.sessionModel` is `"task"` (the SessionStart hook's `Workspace config (workspace.json):` line carries it; read `workspace.json` when that line is not in context) AND the SessionStart hook injected a `Chat record:` line (`{chat}` is its name):
30
30
 
31
31
  - Default behavior: write `braindump_{topic}.md` directly into that chat's drawer at `workspace-scratchpad/chats/{chat}/` — the drawer sits outside `workspace-context/`, so `capture-context.mjs` is not involved
32
32
  - No commit for drawer writes: the drawer is gitignored and machine-local; `/complete-work` lists it and asks what to promote into `workspace-context/`
@@ -23,7 +23,7 @@ node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}
23
23
  - `{chat}` is the name from the `Chat record:` line the SessionStart hook injects. If that line is absent, run `node .claude/scripts/chat-record.mjs --whoami --root "{launcher-root}"` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id against the records. When that too prints nothing (exit 1), omit `--chat` — detection then relies on cwd and the session id alone. Detection also matches this chat's session id against each `work-sessions/*/workspace/session.md` `chatSessions` list, so a lane chat that drove an old-model session by path is findable from the launcher. Pass `--session-id` when the id is known from somewhere else; otherwise it resolves from the chat record's `sessionId`, then `$CLAUDE_CODE_SESSION_ID`, in that order.
24
24
  - `model: session` → continue with this flow (read the session tracker as below), taking `{session-name}` from the detect result's `sessionName`. When detection ran at the launcher the result instead carries `source: "chat-sessions"` and a `sessions` list — present the entries (each `{ name, branch, workItem, status }`) and ask which to finish; `{session-name}` is the chosen entry's `name`.
25
25
  - `model: mixed` → this chat has open tasks AND sessions its id is registered in. List both — each session as `{name} ({status}, {branch})`, each task group as `{branch}` (grouping by branch as below) — and ask which to finish. A session continues this session flow with that `{session-name}`; a task goes to **Task completion (session model v2)**.
26
- - `model: task` → go to **Task completion (session model v2)**. The result's `tasks` come from the chat record; if several are open, ask the user which one to complete — group by branch, a multi-repo task is several entries sharing a branch.
26
+ - `model: task` → go to **Task completion (session model v2)**. The result's `tasks` come from the chat record; if several are open, ask the user which one to complete — group by branch: a multi-repo task is several entries sharing a branch, and one issue may be open on several branches (gh:206), each its own group.
27
27
  - `model: none` → "No active work session. Nothing to complete."
28
28
 
29
29
  Read the full session tracker at `work-sessions/{session-name}/workspace/session.md` (use the frontmatter helper in `.claude/lib/session-frontmatter.mjs` — scripts and hooks use `_utils.mjs` which wraps it).
@@ -375,7 +375,7 @@ Reached from Step 1 when detection says `model: task`. The state is the branch,
375
375
  - `{defaultBranch}` — `workspace.json` → `repos.{repo}.branch`, default `main`; for `.` it is the workspace origin's HEAD with `main` as the fallback — or, when the launcher has no origin, the branch it sits on when that is `main`/`master` — exactly what `task-worktree.mjs`'s `defaultBranchFor` resolves
376
376
  - `{mode}` per repo — `forge` or `local`, resolved exactly as `task-pr.mjs` resolves it: `local` when the repo has no origin remote or its override says so (`repos.{repo}.merge`, or `workspace.merge` for `.`) — the override for a clone whose origin is a third-party upstream nobody here may push to; `forge` when its origin is a forge remote. A local repo is never pushed: it merges into its source clone in step 4.
377
377
 
378
- If several tasks are open, ask the user which one to complete — group by branch; a multi-repo task is several entries sharing a branch — and complete one branch at a time.
378
+ If several tasks are open, ask the user which one to complete — group by branch: a multi-repo task is several entries sharing a branch, and one issue may be open on several branches (gh:206), each its own group — and complete one branch at a time.
379
379
 
380
380
  Notes meant for another chat are not drawer notes. The drawer is per-chat and machine-local — no other chat can read it, and this machine is its only copy — so nothing in it survives a handoff. Anything the next chat (or the operator, from another machine) needs to continue this task — decisions, gotchas, where work stopped — goes on the linked issue as a comment through the tracker adapter (`await tracker.comment("{workItem}", body)`), where every chat can read it.
381
381
 
@@ -409,15 +409,23 @@ Notes meant for another chat are not drawer notes. The drawer is per-chat and ma
409
409
  --out "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json"
410
410
  ```
411
411
 
412
- Omit `--work-item` when the task has none, and repeat `--body-file` once per forge repo. `--chat` resolves the task's repos from this chat's record entries for the branch; when detection came from cwd alone, pass `--repo "{repo}"` once per repo instead. The script resolves each repo's merge mode before doing anything else: a forge repo is pushed `-u origin {branch}` and gets one PR through a per-repo forge aimed at that worktree's own origin, never the launcher's; a local repo (no origin, or `merge: "local"`) is neither pushed nor PR'd — it is recorded as a local entry for step 4. The workspace repo (`.`) is handled exactly like a project repo. It skips repos whose branch has no commits over the base (reporting them as `empty` — no push, no PR, no body file needed, torn down in step 5 like any other; a workspace branch that collected no promotions gets no PR), and stops **before pushing anything** when a repo's origin exists but is not forge-hosted and has no `"local"` override — the error names the `workspace.json` setting (`repos.{repo}.merge`, or `workspace.merge` for `.`) that opts the repo into local mode. The PR title is the linked issue's title, or the branch's first commit subject without a `{workItem}`; the body is the drawer file with `Closes <ref>` appended, where `<ref>` is `#N` when the PR's repo is the tracker's repo and `{tracker-repo}#N` otherwise — only the first form closes an issue in the PR's own repo. If the push is rejected as non-fast-forward — the branch already existed on origin and step 1's rebase rewrote it — the script stops and says so; ask the user, and only on explicit confirmation re-run the same command with `--force-with-lease` (the script never forces on its own). If `workspace.forge` is `false` in `workspace.json` and any repo resolved to forge, the script refuses before pushing anything: forge operations are disabled in this workspace and the PR is opened by hand. `--out` writes the run's JSON to `prs-{slug}.json` for step 4 — `{ prs: [{ repo, mode, … }], empty: [repo…], pushed: [repo…] }`, printed to stdout as well — instead of shell redirection, so the command is identical on every shell. Every entry carries `commits`; a forge entry also carries `owner`, `name`, `number`, `id`, `url`, `isWorkspace`, and a local entry `branch`, `base`, `worktree`. The run is idempotent: a repo whose branch already has an open PR against `{defaultBranch}` gets that PR reused, never duplicated, and if the run fails partway the file still records what was pushed and opened so far — fix the cause and re-run the same command; it completes the set.
412
+ Omit `--work-item` when the task has none, and repeat `--body-file` once per forge repo. `--chat` resolves the task's repos from this chat's record entries for the branch; when detection came from cwd alone, pass `--repo "{repo}"` once per repo instead. The script resolves each repo's merge mode before doing anything else: a forge repo is pushed `-u origin {branch}` and gets one PR through a per-repo forge aimed at that worktree's own origin, never the launcher's; a local repo (no origin, or `merge: "local"`) is neither pushed nor PR'd — it is recorded as a local entry for step 4. The workspace repo (`.`) is handled exactly like a project repo. It skips repos whose branch has no commits over the base (reporting them as `empty` — no push, no PR, no body file needed, torn down in step 5 like any other; a workspace branch that collected no promotions gets no PR), and stops **before pushing anything** when a repo's origin exists but is not forge-hosted and has no `"local"` override — the error names the `workspace.json` setting (`repos.{repo}.merge`, or `workspace.merge` for `.`) that opts the repo into local mode. The PR title is the linked issue's title, or the branch's first commit subject without a `{workItem}`; the body is the drawer file with `Closes <ref>` appended, where `<ref>` is `#N` when the PR's repo is the tracker's repo and `{tracker-repo}#N` otherwise — only the first form closes an issue in the PR's own repo. If the push is rejected as non-fast-forward — the branch already existed on origin and step 1's rebase rewrote it — the script stops and says so; ask the user, and only on explicit confirmation re-run the same command with `--force-with-lease` (the script never forces on its own). If `workspace.forge` is `false` in `workspace.json` and any repo resolved to forge, the script refuses before pushing anything: forge operations are disabled in this workspace and the PR is opened by hand. `--out` writes the run's JSON to `prs-{slug}.json` for step 4 — `{ prs: [{ repo, mode, … }], empty: [repo…], pushed: [repo…] }`, printed to stdout as well — instead of shell redirection, so the command is identical on every shell. Every entry carries `commits` and `mergeApproval` — the repo's resolved merge-approval policy (gh:202: `repos.{repo}.mergeApproval`, or `workspace.mergeApproval` for the workspace repo `.`; `ask` when unset), which step 4 acts on; a forge entry also carries `owner`, `name`, `number`, `id`, `url`, `isWorkspace`, and a local entry `branch`, `base`, `worktree`. The run is idempotent: a repo whose branch already has an open PR against `{defaultBranch}` gets that PR reused, never duplicated, and if the run fails partway the file still records what was pushed and opened so far — fix the cause and re-run the same command; it completes the set.
413
413
 
414
- 4. **Ask before merging, then run `task-pr.mjs --merge`, which also closes the linked issue.** When step 3 reported `prs: []` — every repo was empty — there is nothing to merge: skip this step, say so, and leave the linked issue open for the user to decide (close it by hand, or keep the task going); `--merge` itself refuses an empty PRs file for exactly that reason. Otherwise present a summary per repo that got an entry — forge and local alike, the workspace repo included — built from `--create`'s output, and ask once. A local repo shows `(local merge)` in place of a PR line; a mixed task asks `Merge all?`, an all-local task `Merge all locally?`:
414
+ 4. **Read the checks, then merge under the task's approval policy — `task-pr.mjs --merge` also closes the linked issue.** When step 3 reported `prs: []` — every repo was empty — there is nothing to merge: skip this step, say so, and leave the linked issue open for the user to decide (close it by hand, or keep the task going); `--merge` itself refuses an empty PRs file for exactly that reason. Otherwise read every entry's CI state — the same read `--merge` gates on:
415
+
416
+ ```bash
417
+ node "{launcher-root}/.claude/scripts/task-pr.mjs" --checks --root "{launcher-root}" \
418
+ --prs "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json"
419
+ ```
420
+
421
+ One `{ repo, mode, state, url, failing }` per entry (a local repo reports `none` — no forge, no checks; a GitLab pipeline waiting on a manual job reports `pending` with a `note`). Present a summary per repo that got an entry — forge and local alike, the workspace repo included — built from `--create`'s output plus the check state. A local repo shows `(local merge)` in place of a PR line:
415
422
 
416
423
  ```
417
424
  Task complete:
418
425
 
419
426
  PROJECT: {owner}/{name}
420
427
  PR: {pr.url}
428
+ Checks: {success|pending|failure|none} # failure names its failing jobs
421
429
  Branch: {branch} → {defaultBranch}
422
430
  Commits: {n} # from the entry's `commits` count
423
431
 
@@ -426,34 +434,36 @@ Notes meant for another chat are not drawer notes. The drawer is per-chat and ma
426
434
 
427
435
  WORKSPACE: {ws-owner}/{ws-name} # from the workspace worktree's origin, or "(local merge)"
428
436
  PR: {ws-pr.url}
437
+ Checks: {state}
429
438
  Branch: {branch} → {defaultBranch}
430
-
431
- Merge all? [Y/n]
432
439
  ```
433
440
 
434
- On "n", stop: the PRs stay open and the worktrees, branches, and record entries stay in place — say so.
441
+ The approval policy is each entry's `mergeApproval` field (gh:202), and one `operator` entry anywhere governs the whole task — the workspace PR may never merge ahead of the project PRs it describes, so the task cannot merge part-way; a context-only PR held by its task's code PRs is the accepted limitation:
442
+
443
+ - **ask** (the default) — ask once, after the summary: `Merge all? [Y/n]`, or `Merge all locally? [Y/n]` for an all-local task. On "n", stop: the PRs stay open and the worktrees, branches, and record entries stay in place — say so. On "y", run the merge command below.
444
+ - **operator** — do not ask and do not merge. Report the PR links with their check states and stop there; the PRs wait. The task continues when the operator explicitly says merge in a later turn — then run the merge command below with `--approved`, which the script demands for any operator entry and which this skill passes only on that say-so — or after they merge in the forge UI, where the re-run detects the already-merged PRs, skips them (no `--approved`, no checks), and carries on to the close and the teardown. A local operator entry is done the same way: once its branch is merged into the repo's default branch — by hand or an earlier run — the re-run detects the containment and asks no approval. Keep the drawer's `prs-{slug}.json` until that run happens (it is the command's input); delete the drawer's `pr-{slug}-*.md` bodies and the spent JSON only after a successful merge.
435
445
 
436
- On "y":
446
+ The merge command — on "y" under ask, or the operator's go (add `--approved`):
437
447
 
438
448
  ```bash
439
449
  node "{launcher-root}/.claude/scripts/task-pr.mjs" --merge --root "{launcher-root}" \
440
450
  --prs "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json" --work-item "{workItem}"
441
451
  ```
442
452
 
443
- The script checks each entry's state first, then finishes the project entries — forge PRs (squash, delete branch) and local branches as `git merge --ff-only {branch}` in the repo's source clone (`repos/{repo}`, or the launcher for `.`), which must sit clean on its default branch — then the workspace entry only when every project merge succeeded: the workspace branch's promoted context describes the project merges and must never merge ahead of them. A PR the forge already reports as merged and a local branch already contained in the default branch both count as done, which is what makes a re-run safe: the run that finally gets every entry merged also does the pull and the close. A local branch that will not fast-forward stops the run and says to rebase the task branch onto the local `{defaultBranch}` and re-run. The launcher is pulled `--ff-only` only when it sits on the workspace default branch, `.` was not itself merged locally (that merge ran in the launcher — `pullSkipped: "workspace merged locally"`), the workspace repo is not local (no forge merge of `.` happened that a pull could fetch — `pullSkipped: "workspace repo is local"`), and the launcher branch has an upstream to pull from (`pullSkipped: "launcher has no upstream"`); a launcher on some other branch reports `pullSkipped: "launcher on <branch>"`, and a failed pull is reported as `pullFailed: true` rather than an error, because the merges stand and the issue still closes — a failed pull is the one case that needs the launcher pulled by hand before step 5. The close comes last, with a one-line comment naming each merged PR URL or local `{repo} {branch}->{base}`: merge precedes close because an issue closed before its PR merges points at work that never landed. It happens only when `{workItem}` is given AND a tracker is configured — without a work item or without a tracker, `closed` is `null` and the JSON says why (`closeSkipped`) — say so to the user. On any merge failure the script stops, names what is still open, and exits non-zero — nothing is torn down, and re-running `--merge` once the failure is fixed is safe. After a successful merge, delete the drawer's `pr-{slug}-*.md` body files and the spent `prs-{slug}.json`.
453
+ Before the first merge the script reads the checks of every forge PR that is not already merged — all of them up front, so the run is all-or-nothing (gh:201): any failure, or any entry still pending, stops it with every entry's state listed and nothing merged, not even the green PRs ahead of the red one — re-run once checks finish, or pass `--wait`, which polls every still-pending entry each 20-second round up to `--wait-timeout {min}` minutes (default 30, counted after the settle) and, once the last pending entry goes terminal, re-reads every entry once more so a PR that went green early and red during the wait is still caught. Checks can lag PR creation: a PR opened within the last 90 seconds that reports no checks is re-read before `none` is accepted — every such PR per round, one shared 30-second pause, twice at most — a fresh `none` may be a race rather than a verdict. `none` (no CI) and `success` proceed to the merges, in order, un-re-gated. `--skip-checks` bypasses the gate and is recorded as `checksSkipped` in the output — reach for it only on the user's say-so. Local entries skip checks: there is no forge to ask. Beyond the gate, the script finishes the project entries — forge PRs (squash, delete branch) and local branches as `git merge --ff-only {branch}` in the repo's source clone (`repos/{repo}`, or the launcher for `.`), which must sit clean on its default branch — then the workspace entry only when every project merge succeeded: the workspace branch's promoted context describes the project merges and must never merge ahead of them. A PR the forge already reports as merged and a local branch already contained in the default branch both count as done — and read no checks — which is what makes a re-run safe: the run that finally gets every entry merged also does the pull and the close. A local branch that will not fast-forward stops the run and says to rebase the task branch onto the local `{defaultBranch}` and re-run. The launcher is pulled `--ff-only` only when it sits on the workspace default branch, `.` was not itself merged locally (that merge ran in the launcher — `pullSkipped: "workspace merged locally"`), the workspace repo is not local (no forge merge of `.` happened that a pull could fetch — `pullSkipped: "workspace repo is local"`), and the launcher branch has an upstream to pull from (`pullSkipped: "launcher has no upstream"`); a launcher on some other branch reports `pullSkipped: "launcher on <branch>"`, and a failed pull is reported as `pullFailed: true` rather than an error, because the merges stand and the issue still closes — a failed pull is the one case that needs the launcher pulled by hand before step 5. The close comes last, with a one-line comment naming each merged PR URL or local `{repo} {branch}->{base}`: merge precedes close because an issue closed before its PR merges points at work that never landed. It happens only when `{workItem}` is given AND a tracker is configured — without a work item or without a tracker, `closed` is `null` and the JSON says why (`closeSkipped`) — say so to the user. On any merge failure the script stops, names what is still open, and exits non-zero — nothing is torn down, and re-running `--merge` once the failure is fixed is safe. After a successful merge, delete the drawer's `pr-{slug}-*.md` body files and the spent `prs-{slug}.json`.
444
454
 
445
455
  5. **Tear down only what finished — worktree first, then the record entry**, and only for a repo whose PR merged or whose local merge succeeded in step 4, or whose branch was empty and never pushed in step 3; the workspace repo included (`--repo "."`). If a repo's push, PR, or merge failed, or the user declined the merge, leave that repo's worktree, branch, and record entry exactly in place and say so: `--delete-branch` would otherwise `branch -D` commits that exist nowhere but the local worktree. For `.` the script never deletes the branch checked out at the launcher root.
446
456
 
447
457
  ```bash
448
458
  node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}" --remove --repo "{repo}" --branch "{branch}" --delete-branch
449
- node "{launcher-root}/.claude/scripts/chat-record.mjs" --root "{launcher-root}" --remove-task --chat "{chat}" --work-item "{workItem}" --repo "{repo}"
459
+ node "{launcher-root}/.claude/scripts/chat-record.mjs" --root "{launcher-root}" --remove-task --chat "{chat}" --work-item "{workItem}" --branch "{branch}" --repo "{repo}"
450
460
  ```
451
461
 
452
- When the task has no `{workItem}`, drop `--work-item` and pass `--branch "{branch}"` instead — the record entry is keyed by repo + branch. Worktree first because a refusal (dirty worktree, slug collision) then leaves both the worktree and its record entry in place — nothing orphaned, safe to retry. `--delete-branch` also removes the local branch: the forge's `deleteBranch` removed only the remote one, so post-merge teardown passes it to clean the local clone; this is the only step that ever passes it. Never pass `--force` without asking the user.
462
+ `--branch` is always passed: one issue may be open on several branches (gh:206), and without it the removal would take every branch of the item/repo — a sibling still in flight included. When the task has no `{workItem}`, drop `--work-item` — the entry is then keyed by repo + branch, which `--branch` selects on its own. Worktree first because a refusal (dirty worktree, slug collision) then leaves both the worktree and its record entry in place — nothing orphaned, safe to retry. `--delete-branch` also removes the local branch: the forge's `deleteBranch` removed only the remote one, so post-merge teardown passes it to clean the local clone; this is the only step that ever passes it. Never pass `--force` without asking the user.
453
463
 
454
464
  ## Notes
455
465
  - The session tracker's body is the primary source for PR-body synthesis — it captures the full session history alongside specs and plans
456
- - All repos get PRed and merged together — one approval for all
466
+ - All repos get PRed and merged together — one approval for all, unless a repo's `mergeApproval` says `operator` (gh:202), in which case the PRs wait for the operator's merge
457
467
  - Version bumps, tags, and publish happen in `/release`, not `/complete-work` — this avoids version drift when multiple feature branches land between releases
458
468
  - The teardown order is mandatory: project worktrees first, then workspace worktree, then prune, then delete the session folder
459
469
  - Context promotion and cleanup are intentional workflow behavior — they bypass normal commit conventions by design
@@ -26,7 +26,7 @@ When called within an active work session (the active-session pointer at `.claud
26
26
  git commit -m "handoff: update {session-name} tracker"
27
27
  ```
28
28
 
29
- Under the task model — `workspace.sessionModel` is `"task"` in `workspace.json` AND the SessionStart hook injected a `Chat record:` line (`{chat}` is its name):
29
+ Under the task model — `workspace.sessionModel` is `"task"` (the SessionStart hook's `Workspace config (workspace.json):` line carries it; read `workspace.json` when that line is not in context) AND the SessionStart hook injected a `Chat record:` line (`{chat}` is its name):
30
30
 
31
31
  - Default behavior: write `handoff_{topic}.md` directly into that chat's drawer at `workspace-scratchpad/chats/{chat}/` — the drawer sits outside `workspace-context/`, so `capture-context.mjs` is not involved
32
32
  - No commit for drawer writes: the drawer is gitignored and machine-local; `/complete-work` lists it and asks what to promote into `workspace-context/`
@@ -44,17 +44,21 @@ For each non-gitignored workspace-context `.md` and each `work-sessions/*/worksp
44
44
  ### 3. Workspace structure
45
45
  The script checks that workspace.json and CLAUDE.md are present and parseable, that `workspace-context/` and `.claude/rules`, `skills`, `scripts` directories exist, and that every manifest repo is cloned under `repos/`.
46
46
 
47
+ It also checks the template-modification registry (`.claude/template-modifications.json`, the workspace's record of files it owns outright or deliberately edits): legacy keys still in workspace.json (`workspace.localFiles`, `workspace.templateModifications`), keys that escape `.claude/` (the registry covers `.claude/` paths only — root files are handled by their own merge paths), and registrations whose file has gone back to the template's baseline content are info — `/workspace-update` offers the cleanups. A registry that doesn't parse is a warning: its localFiles exclusions and reasons are being ignored, and the update flow stops on it.
48
+
47
49
  ### 4. Git state
48
50
  The script covers the launcher itself: on its default branch, tracked tree clean. Untracked paths are info — gitignored content is not counted.
49
51
 
50
- The worktree-level checks are not carried by the script; perform them alongside it:
52
+ It also lists leftover Claude Code agent worktrees (gh:205). A subagent with worktree isolation gets `.claude/worktrees/agent-{id}/` on a `worktree-agent-{id}` branch — in the workspace repo and in each `repos/*/`. Claude Code removes a clean one when the subagent finishes and sweeps the rest past `cleanupPeriodDays`, but the sweep keeps any worktree holding work, and a subagent that commits without pushing is exactly that, indefinitely. Per its docs, Claude Code locks a worktree while its agent runs. Each leftover is one info finding plus a `summary.agentWorktrees` entry (`--json`) carrying the facts and a scripted verdict that fails closed: `removable` is true only when the directory still matches git's record of the worktree (not prunable, toplevel intact), the worktree is clean (untracked files count as dirty), it holds no work of its own (`commitsBeyond: 0`, or only non-merge commits whose patch-ids all landed on `base` per `git cherry` — which sees rebase and cherry-pick landings `--merged` misses; a squash merge leaves patch-ids unmatched, so a squash-merged branch is deliberately not removable), it is not locked, it has been idle for over an hour (newest of the HEAD reflog entry and the directory mtime), and no chat-record task claims the branch — records that cannot be read count as unknown, not none. `reasons` lists every failed gate. Cleanup step 9 offers removal only for `removable: true` entries — the safety call is scripted, not re-derived from prose.
53
+
54
+ The remaining worktree-level checks are not carried by the script; perform them alongside it:
51
55
  - Worktrees with no recent commits? (orphaned)
52
56
  - Local branches with no remote tracking? (unpushed work)
53
57
  - Worktrees whose branch has already been merged? (cleanup candidates)
54
58
  - Orphan worktree records in project repos — run `git -C repos/{repo} worktree list` for each repo and flag any `prunable` markers. These usually come from a workspace-first teardown (the unsafe order) leaving stale admin records behind. Suggest `git worktree prune` on the affected repo.
55
59
  - Task-model state (gh:146), three checks:
56
60
  - **Unrecorded task worktrees** — list `repos/*/.claude/worktrees/*` and `.claude/worktrees/*`, read each candidate's branch (`git -C "{path}" rev-parse --abbrev-ref HEAD`), and keep only those on a task-prefixed branch (`feature/`, `bugfix/`, `chore/`) — Claude Code's own worktrees carry other branch names, so the prefix filter skips them without guessing a name convention. Cross-reference the chat records (`node .claude/scripts/chat-record.mjs --root . --list`): a task-prefixed worktree no record entry claims is *unrecorded* — it may be a legitimate no-tracker task (those are never recorded), so present it and ask before suggesting `node .claude/scripts/task-worktree.mjs --root . --remove --repo "{repo}" --branch "{branch}"`.
57
- - **Stale record entries** — a record task entry whose worktree is gone (neither `repos/{repo}/.claude/worktrees/{slug}/` nor, for `repo: "."`, `.claude/worktrees/{slug}/` exists). Suggest `node .claude/scripts/chat-record.mjs --root . --remove-task --chat "{chat}" --work-item "{workItem}" --repo "{repo}"` (omit `--repo` when the entry has none).
61
+ - **Stale record entries** — a record task entry whose worktree is gone (neither `repos/{repo}/.claude/worktrees/{slug}/` nor, for `repo: "."`, `.claude/worktrees/{slug}/` exists). Suggest `node .claude/scripts/chat-record.mjs --root . --remove-task --chat "{chat}" --work-item "{workItem}" --branch "{branch}" --repo "{repo}"` (omit `--repo` when the entry has none). Always pass `--branch`: without it the removal takes every branch of the item/repo (gh:206), a sibling branch with a live worktree included.
58
62
  - **Merged but never completed** — a recorded task branch that already merged. Judge merged-ness by the forge's merged PRs for that repo, matching on head branch — never `git branch --merged`, which a squash merge (never an ancestor) silently misses. `/complete-work` never ran. Suggest running `/complete-work` for that branch (detection from the chat record finds it).
59
63
 
60
64
  ### 5. Workspace-context auto-file integrity
@@ -105,6 +109,7 @@ When stale candidates are found, surface them as warnings in the output format a
105
109
  - `work-sessions/{name}/` folders whose worktrees are gone — suggest cleanup
106
110
  - Session trackers whose branches have been merged — suggest `/complete-work` post-flight cleanup
107
111
  - Unrecorded task-prefixed worktrees (no chat-record entry claims them; may be no-tracker tasks) — ask, then suggest `task-worktree.mjs --remove`
112
+ - Leftover Claude Code agent worktrees (gh:205) — the audit script's info findings (or `summary.agentWorktrees` via `--json`) carry each one's facts with a scripted `removable` verdict and the `reasons` behind it. Offer removal one worktree at a time, always asking first, and only for entries marked `removable: true` — the script has already verified the directory matches git's record of the worktree, it is clean (untracked files count as dirty), it holds no work of its own (`commitsBeyond: 0`, or only non-merge commits whose patch-ids all landed on the base per `git cherry`; a squash merge leaves patch-ids unmatched and is deliberately not removable), it is unlocked, idle for over an hour, and claimed by no chat-record task. Never widen these criteria by hand. For `removable: false` entries, report the `reasons` and leave the worktree alone — locked means locked: Claude Code considers an agent running, and nothing in this flow overrides it. Removal, run from the launcher root, is `git -C <repo-dir> worktree remove <absolute-path>` (never `-f` — a refusal means the worktree no longer matches the recorded facts) then `git -C <repo-dir> branch -D <branch>`, with `<repo-dir>` the owning repo — the workspace root itself for `repo: "."`, `repos/{repo}/` otherwise — and `<absolute-path>` the entry's `absolutePath` fact. Never touch task worktrees: task-prefixed branches never reach the agent list, and claimed agent branches are already excluded by the script.
108
113
  - Chat-record task entries whose worktree is gone — suggest `chat-record.mjs --remove-task`
109
114
  - Recorded task branches already merged (per the forge's merged PRs, not `git branch --merged`) — suggest `/complete-work`
110
115
  - Braindumps that overlap significantly — suggest merging (e.g., "workspace-branching.md and persistent-work-sessions.md cover the same topic")
@@ -217,7 +222,7 @@ OK (6):
217
222
 
218
223
  ## Flow
219
224
 
220
- 1. Run `node .claude/scripts/maintenance-audit.mjs --root .` and present its report — it carries audit sections 1–7. Add `--offline` when there is no network (section 7 is the only network user), or `--json` when step 13's health metrics want the machine-readable `summary.canonical` / `summary.alwaysLoaded` numbers.
225
+ 1. Run `node .claude/scripts/maintenance-audit.mjs --root .` and present its report — it carries audit sections 1–7. Add `--offline` when there is no network (section 7 is the only network user), or `--json` when step 13's health metrics or cleanup step 9's agent-worktree pass want the machine-readable `summary.canonical` / `summary.alwaysLoaded` / `summary.agentWorktrees` numbers.
221
226
  2. Perform the residual checks the script does not carry — the worktree-level git checks from audit section 4 (orphaned worktrees, unpushed branches, merged worktrees, prunable worktree records, the task-model checks against `chat-record.mjs --list`).
222
227
  3. Read session-log.jsonl if it exists (feeds step 13's session log stats)
223
228
  4. If cleanup mode: run `node .claude/scripts/build-workspace-context.mjs --check --root .` — exit `0` = clean (and within budget when one is set), `1` = artifact missing or stale → regenerate with `--write`, `2` = artifacts current but canonical body over budget (only possible with a budget set). The `canonical` block in the JSON drives the triage decision; `"budget": null` means the canonical budget is off. Then compare context files pairwise for overlap and scan for stale cross-references; if the post-regen `--check` reports `over-budget`, enter the canonical-budget triage flow described in cleanup step 11.
@@ -37,7 +37,7 @@ Two checks before anything is pushed. First the tag: if `v{version}` already exi
37
37
  git -C repos/{repo} ls-remote --exit-code origin refs/tags/v{version}
38
38
  ```
39
39
 
40
- Second the release path. Build the repo's forge with `forgeConfigForRepo(root, '{repo}', ws)` from `.claude/scripts/merge-mode.mjs` — it picks the adapter from the repo's own origin, so a GitLab repo gets the gitlab adapter whatever `workspace.forge` says — and decide now who creates the release (Step 5): the repo's CI, or this skill. If the flow needs something the forge cannot do, stop here — past this point the tag is pushed, and a publish that cannot run is much harder to unwind.
40
+ Second the release path. Build the repo's forge with `forgeConfigForRepo(root, '{repo}', ws)` from `.claude/scripts/merge-mode.mjs` — it picks the adapter from the repo's own origin, so a GitLab repo gets the gitlab adapter whatever `workspace.forge` says — and decide now who creates the release (Step 6): the repo's CI, or this skill. If the flow needs something the forge cannot do, stop here — past this point the tag is pushed, and a publish that cannot run is much harder to unwind.
41
41
 
42
42
  **Step 3: Bump on a branch**
43
43
 
@@ -47,18 +47,32 @@ Create a task worktree for the release branch:
47
47
  node .claude/scripts/task-worktree.mjs --root . --create --repo "{repo}" --branch "release/v{version}"
48
48
  ```
49
49
 
50
- If the repo has a `package.json` with a `version`, set it to `{version}` (edit the JSON; keep formatting) and update `package-lock.json`'s top-level version fields if that file is present. Commit `chore: release v{version}`. If the repo has no version file, skip the commit — step 5 tags the current default-branch head instead.
50
+ If the repo has a `package.json` with a `version`, set it to `{version}` (edit the JSON; keep formatting) and update `package-lock.json`'s top-level version fields if that file is present. Commit `chore: release v{version}`. If the repo has no version file, skip the commit — step 6 tags the current default-branch head instead.
51
51
 
52
52
  **Step 4: Merge**
53
53
 
54
54
  Push the branch and open a PR through the same per-repo forge (`forgeConfigForRepo` from Step 2) with head `release/v{version}`. Ask `Merge? [Y/n]`, then merge (squash, delete branch).
55
55
 
56
- **Step 5: Tag and publish**
56
+ **Step 5: Leak audit (optional)**
57
57
 
58
- Pull the merge, tag it, and push the tag:
58
+ Pull the merge, then audit what is about to be tagged:
59
59
 
60
60
  ```bash
61
61
  git -C repos/{repo} pull --ff-only
62
+ node .claude/scripts/release-leak-audit.mjs --root . --repo "{repo}"
63
+ ```
64
+
65
+ Patterns live in `workspace.json` — `repos.{repo}.release.leakPatterns` for one repo, `workspace.release.leakPatterns` for every repo — as arrays of strings: `/…/flags` is that regex, anything else a case-insensitive literal. Mind the seam between the forms: a path-like literal whose tail parses as a flags run (`/keys/i`) reads as a regex — write such literals without the leading slash or escape them as a regex.
66
+
67
+ The script scans what this release publishes: for a publishable package the `npm pack --dry-run --ignore-scripts` file list (the root package only — a monorepo's workspace packages are not enumerated, and build artifacts a publish workflow generates only in CI are not in npm's local list) together with the files changed since the last tag, else the changed files alone (`--tag-range <from>..<to>` overrides, reading contents from the range's end commit when it is not HEAD) — plus every commit message in the range, squash-merged PR bodies included. The worktree must be clean, since contents are read from disk (`--allow-dirty` overrides). Matched spans are masked in the excerpt, so showing a match never echoes the secret.
68
+
69
+ Exit 1 lists matches as `{ file, line, pattern, excerpt }` plus a `skipped` list of files it could not read: stop and show them; never edit files to silence the audit. Exit 0 with `no leak patterns configured` is the no-patterns case — the step still runs and exits 0, so a workspace that never opts in needs no special-casing. Exit 2 means the audit itself could not run (including a dirty worktree); report that too. Whether a match is a true leak, an over-broad pattern, or a release to redo is the operator's call — cheap before the tag is pushed, impossible after.
70
+
71
+ **Step 6: Tag and publish**
72
+
73
+ Tag the merge and push the tag:
74
+
75
+ ```bash
62
76
  git -C repos/{repo} tag v{version}
63
77
  git -C repos/{repo} push origin v{version}
64
78
  ```
@@ -86,7 +100,7 @@ await forge.releaseCreate({ tag: 'v{version}', repo, generateNotes: false,
86
100
 
87
101
  If a `publish.yml` (or a publish job) without release creation exists, still find and watch its run the same way.
88
102
 
89
- **Step 6: Tear down and report**
103
+ **Step 7: Tear down and report**
90
104
 
91
105
  Remove the release worktree:
92
106
 
@@ -96,7 +110,7 @@ node .claude/scripts/task-worktree.mjs --root . --remove --repo "{repo}" --branc
96
110
 
97
111
  Report the PR, the tag, the release URL, and the publish status.
98
112
 
99
- **Step 7: Update workspace release state**
113
+ **Step 8: Update workspace release state**
100
114
 
101
115
  If the workspace keeps release state in `workspace-context/` (for example a current-release line in a status file under `shared/locked/`), offer to update it — through a workspace task worktree and PR, never on the launcher.
102
116
 
@@ -130,13 +130,36 @@ For (5): exit — no changes.
130
130
  glab issue list --repo {group/sub/project} --per-page 5
131
131
  ```
132
132
 
133
- ### Step 5: Report
133
+ ### Step 5: Configure epics (optional)
134
+
135
+ Epics group related issues across a release or theme. Two modes, both through the adapter:
136
+
137
+ - **Label mode** — the default, works on any plan, both backends. An epic is the label `epic:{name}` carried by every issue in it. Nothing to configure unless the team already uses that prefix for something else: `epicLabelPrefix` renames it (e.g. `"E:"` — any delimiter-terminated prefix works; a trailing alphanumeric is rejected), and existing labels under the chosen prefix become epics as they are. On GitLab, `epicLabelPrefix: "epic::"` makes epics **scoped labels** — GitLab itself then enforces one epic per issue.
138
+ - **Native mode** — GitLab only, `"epics": "native"`: real group epics with ids and URLs, living at the project's parent group. Needs Premium/Ultimate on that instance. Without it every epic call fails with a clear error pointing back at label mode — the adapter never silently falls back, so a native-configured team finds out at the first call instead of discovering epics split across two representations. GitHub has no native epic object: `"native"` on `github-issues` throws from every epic method (sub-issues may back one later).
139
+
140
+ Ask: "Group work into epics? [label/skip]" — plus `native` as a third choice when the backend is gitlab-issues. Default skip. For label or native, write the key into the `workspace.tracker` block (`{"epics": "label"}` or `{"epics": "native"}`; plain label mode needs no key at all). If the user wants starter epics, create them through the adapter — idempotent, so re-running setup duplicates nothing:
141
+
142
+ ```bash
143
+ node --input-type=module -e "
144
+ import { createTracker } from './.claude/scripts/trackers/interface.mjs';
145
+ import { readFileSync } from 'node:fs';
146
+ const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
147
+ const t = createTracker(ws.workspace.tracker);
148
+ await t.createEpic({ name: 'auth', description: 'Authentication work' });
149
+ console.log((await t.listEpics()).map((e) => e.name).join(', '));
150
+ "
151
+ ```
152
+
153
+ `/start-work` reads the epic list before creating a new issue and offers an epic picker once at least one epic exists; a workspace with none sees no change.
154
+
155
+ ### Step 6: Report
134
156
 
135
157
  ```
136
158
  Tracker configured:
137
159
  Type: {github-issues | gitlab-issues}
138
160
  Repo: {slug}
139
161
  Labels: bug, feat, chore, P1, P2, P3
162
+ Epics: {label mode (epic:{name} labels) | native (GitLab group epics) | off}
140
163
  Milestones: {list or "(none — add via the forge UI)"}
141
164
 
142
165
  Next: run /start-work to pick or create an issue and begin.
@@ -5,7 +5,7 @@ description: Begin or resume a work session. Creates a self-contained work-sessi
5
5
 
6
6
  # Start Work
7
7
 
8
- Two lifecycles share this skill. `workspace.sessionModel` in `workspace.json` selects for new work: `"task"` routes new work to **Flow: Task** (session model v2 — no session folder, no `session.md`); absent or `"session"` keeps the existing flows below, unchanged. Resuming an existing `work-sessions/` session always uses the existing Resume flow regardless of the setting.
8
+ Two lifecycles share this skill. `workspace.sessionModel` in `workspace.json` selects for new work: `"task"` routes new work to **Flow: Task** (session model v2 — no session folder, no `session.md`); absent or `"session"` keeps the existing flows below, unchanged. Resuming an existing `work-sessions/` session always uses the existing Resume flow regardless of the setting. CLAUDE.md no longer imports `workspace.json`: take `sessionModel`, `tracker`, and the repo names from the SessionStart hook's `Workspace config (workspace.json):` line when it is in context, else read `workspace.json`.
9
9
 
10
10
  Begin or resume a persistent work session. Each session lives in its own `work-sessions/{name}/` folder containing one workspace worktree, nested project worktrees, and a unified `session.md` tracker. Sessions can run in parallel from separate terminals.
11
11
 
@@ -19,7 +19,7 @@ Begin or resume a persistent work session. Each session lives in its own `work-s
19
19
 
20
20
  New work as a task: one tracker issue, one branch, one worktree per repo the work touches. This flow creates no `work-sessions/` folder, no `session.md`, and seeds no task list — the issue, the branch, and the chat record are the entire state. The chat stays at the workspace root — `{launcher-root}`, the absolute path on the `Workspace root:` line the SessionStart hook injects (at /start-work time you are normally already there); the worktrees are reached by path.
21
21
 
22
- If `workspace.tracker` is absent, say tracking is off and skip step 1 — but still ask for the type (`bug` / `feat` / `chore`) and a one-line description, because the type picks the branch prefix — then continue with steps 2–6. What that costs: without a tracker there is no `workItem` and no issue to close at completion — the task is still recorded on the chat record (with no work item, keyed by repo + branch), so `/complete-work` finds it from the launcher like any other task.
22
+ If `workspace.tracker` is absent (the hook's `Workspace config` line reads `tracker: off`), say tracking is off and skip step 1 — but still ask for the type (`bug` / `feat` / `chore`) and a one-line description, because the type picks the branch prefix — then continue with steps 2–6. What that costs: without a tracker there is no `workItem` and no issue to close at completion — the task is still recorded on the chat record (with no work item, keyed by repo + branch), so `/complete-work` finds it from the launcher like any other task.
23
23
 
24
24
  1. **Identify or create the tracker issue and claim it.** If the invocation's arguments already name an issue — `gh:N`, `#N`, or an issue URL — normalize it to the adapter's id (`#42` and a `.../issues/42` URL both mean `gh:42`) and fetch it with `tracker.getIssue(id)`. Then check whether the issue already has a task before claiming anything — another chat may have started it (gh:188):
25
25
 
@@ -27,8 +27,8 @@ If `workspace.tracker` is absent, say tracking is off and skip step 1 — but st
27
27
  node .claude/scripts/chat-record.mjs --root . --owner "{workItem}"
28
28
  ```
29
29
 
30
- Exit 1 means no chat owns a task for the issue: claim it when it is not yet assigned to you (with the same `ALREADY_ASSIGNED` handling as the fallback pick below) and skip the candidate list entirely. Exit 0 prints `{ chat, branch, repos }` — a task exists. First make sure its worktrees are there: run step 4's `--create` for each of `repos`; over an existing branch or worktree `--create` REUSES what it finds (`created: false`) — it never fails or duplicates. Then ask the operator which way to take it:
31
- - **Adopt into this chat** — record the task with step 5's `--add-task`, and remove it from the old chat's record (`--remove-task --chat "{owner.chat}"` with the same selectors) only when the operator says that chat is done with it. While both chats still work the task, both records legitimately list it — the branch and worktrees are shared, not duplicated.
30
+ Exit 1 means no chat owns a task for the issue: claim it when it is not yet assigned to you (with the same `ALREADY_ASSIGNED` handling as the fallback pick below) and skip the candidate list entirely. Exit 0 prints `{ chat, branches }` — a task exists, one `{ branch, repos }` per branch the issue is open on (gh:206: one issue may carry several branches, each its own worktree and PR). First make sure the worktrees are there: run step 4's `--create` once per branch, for each repo in that branch's `repos`; over an existing branch or worktree `--create` REUSES what it finds (`created: false`) — it never fails or duplicates. Then ask the operator which way to take it:
31
+ - **Adopt into this chat** — record each adopted branch with step 5's `--add-task`, and remove it from the old chat's record (`--remove-task --chat "{owner.chat}" --work-item "{workItem}" --branch "{branch}" --repo "{repo}"`) only when the operator says that chat is done with it. While both chats still work a branch, both records legitimately list it — the branch and worktrees are shared, not duplicated — and a sibling branch the owner keeps is not this adoption's to take.
32
32
  - **Leave it with the owner** — message the owning chat via SendMessage using the name `--owner` printed (the record name doubles as the session-registry name SendMessage reaches a chat by) and stop here.
33
33
  - When `--owner` names this chat, it is a resume of your own task: reuse the worktrees as above and continue.
34
34
 
@@ -57,6 +57,8 @@ If `workspace.tracker` is absent, say tracking is off and skip step 1 — but st
57
57
  await tracker.claim(newIssue.id);
58
58
  ```
59
59
 
60
+ The epic flow from Blank step 6 applies here too — `tracker.listEpics()` read before the create (a failure there is reported and skipped, never fatal), the picker offered only when it is non-empty.
61
+
60
62
  Remember `workItem: {issue.id}`.
61
63
 
62
64
  2. **Pick repo(s)** — the same numbered multi-select as Blank step 7 (e.g. `1,3` or `all`), defaulting to the repo marked `"primary": true` under `repos` in `workspace.json`, falling back to the first entry when none is marked. The list also offers the **workspace repo itself**, shown as `workspace (this repo)` and addressed as `.`. Include it when the task changes anything tracked in the workspace repo — `workspace-context/`, the workspace's own `.claude/` (rules, hooks, scripts, skills), or, in a dogfood workspace, mirrors of template changes. Steps 4 and 5 take `.` like any other repo name (`--repo "."`).
@@ -192,8 +194,15 @@ If no gap is found, skip silently.
192
194
 
193
195
  6. **User picked "Something new" (or fell through from step 2 with no tracker).**
194
196
  - Ask for a description, type (`bug` / `feat` / `chore`), priority (`P1` / `P2` / `P3`), optional milestone.
195
- - If a tracker is configured, create the issue and self-assign:
197
+ - If a tracker is configured, read the epic list first — a tracker/epic failure must surface before anything is created, and it never blocks issue creation — then create the issue and self-assign:
196
198
  ```javascript
199
+ let epics = [];
200
+ try {
201
+ epics = await tracker.listEpics();
202
+ } catch (e) {
203
+ console.log(`Skipping the epic picker — could not list epics: ${e.message}`);
204
+ }
205
+
197
206
  const newIssue = await tracker.createIssue({
198
207
  title: description,
199
208
  body: `Created at /start-work by ${user}.`,
@@ -201,7 +210,16 @@ If no gap is found, skip silently.
201
210
  milestone: milestone || null,
202
211
  });
203
212
  await tracker.claim(newIssue.id);
213
+
214
+ // Offered only when epics already exist; teams without them see
215
+ // nothing new. Present a numbered menu: the existing epics, "[0] No
216
+ // epic", and "[N] New epic…". Create only on the explicit new-epic
217
+ // choice (tracker.createEpic({ name })), then assign the pick:
218
+ if (epics.length > 0) {
219
+ await tracker.setIssueEpic(newIssue.id, chosenName /* or null for no epic */);
220
+ }
204
221
  ```
222
+ `setIssueEpic` replaces any epic the issue already carries, and an unknown name throws — never invent epic names to satisfy it. If it itself fails after the issue exists, report that and continue: the issue is already created and claimed, and nothing after that point is worth aborting over.
205
223
  Remember `workItem: {newIssue.id}` for the session tracker.
206
224
  - If no tracker: proceed without a `workItem:` linkage — the session is a pure blank.
207
225
 
@@ -9,7 +9,7 @@ Apply a staged template update to an initialized workspace. The CLI (`npx @ulyss
9
9
 
10
10
  ## Prerequisites
11
11
 
12
- - `workspace.json` must have `initialized: true`
12
+ - Read `workspace.json` (the file at the workspace root — the SessionStart hook's `Workspace config (workspace.json):` line is only a summary) and check `workspace.initialized` is `true`
13
13
  - If not initialized, check whether initialization was committed but never merged — the workspace-init flow ends with its commits merged to the default branch, so an unmerged init branch explains a missing flag:
14
14
  ```bash
15
15
  git log --all --format=%H -S'"initialized": true' -- workspace.json
@@ -54,14 +54,19 @@ It runs from the payload precisely so workspaces that don't have it installed ye
54
54
  - `new` — no installed counterpart and no baseline entry; safe to batch-apply (Step 3) behind one confirmation
55
55
  - `identical` — installed file already equals the payload; skip silently
56
56
  - `updated` — installed file still holds the baseline content while the payload ships something new: a pure template change the user never touched. Batched with `new` behind one confirmation
57
- - `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision — Step 3 merges these three-way when a merge base is staged
57
+ - `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision — Step 3 merges these three-way when a merge base is staged. A path registered in `.claude/template-modifications.json` (see the registry note below) enters as `{ file, reason }`, its reason saying why the file diverges — show it at every decision about the file
58
58
  - `config` — `.mcp.json` and `.claude/settings.json`: JSON the workspace owns jointly with the template — never compared by content, never batch-copied. Each entry carries a key-level diff (`added` keys the template ships, `workspaceOnly` keys only the workspace has, `changed` keys with different values; nested paths like `mcpServers/{server}`), element diffs for array-valued keys (`arrays`: `{ path, added, workspaceOnly }` element lists for `hooks/{event}`, `permissions/allow`/`deny`), or a `notInstalled` / `unparseable` flag. Merged key by key in Step 3.
59
- - `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied
60
- - `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list
59
+ - `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied; registered entries carry their reason, shown in the summary
60
+ - `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list; registered entries carry their reason, shown with that ask
61
61
  - `activated` — the payload ships `rules/{name}.md.skip` while the workspace keeps `{name}.md` active: the rule was deliberately activated. Nothing to install — the active rule stays.
62
- - `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically. An entry is a plain path, `{ file, referencedBy }` — a hook a workspace-only `settings.json` entry still registers (the paths say where) — or `{ file, userOwned: true }` — no baseline record, so the template never shipped it and it is the workspace's own.
62
+ - `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files claimed in `.claude/template-modifications.json` → `localFiles` (an array of `.claude/`-relative paths or globs the workspace owns; the legacy `workspace.json` → `workspace.localFiles` array still counts) are excluded automatically. An entry is a plain path, `{ file, referencedBy }` — a hook a workspace-only `settings.json` entry still registers (the paths say where) — or `{ file, userOwned: true }` — no baseline record, so the template never shipped it and it is the workspace's own; any of these may carry `reason` when the file is registered in the registry's `modifications` map.
63
63
  - `staleTests` — `*.test.mjs` files under `.claude/` the payload doesn't carry. The package never ships tests, so these came from a dev checkout and no update refreshes them (Step 3 offers removal).
64
64
  - `implicitDefaults` — `workspace.json` keys whose absence carried a default in the version being upgraded from. Today: `canonicalBudgetBytes`, an implicit 40960-byte budget between v0.15.0-beta.1 and v0.19.0-beta.0 (before v0.15 there was no budget at all; since v0.19 absent means off). An upgrade from inside that window into a workspace.json that never set the key reports `{ key, value, reason }` — Step 3's workspace.json step writes the value explicitly so trimming doesn't silently stop.
65
+ - `staleModifications` — `{ file, reason }` entries from the registry whose installed file already equals the payload: the template's version was taken at some point, so the recorded reason describes nothing. `file` is the `.claude/`-prefixed path; the registry key is that path with the prefix stripped. Step 3 offers to drop them.
66
+ - `legacyKeys` — template-modification data still sitting in `workspace.json` (`workspace.localFiles`, `workspace.templateModifications`) instead of the registry file. Step 3 offers the migration.
67
+ - `ignoredKeys` — registry keys that escape `.claude/` (`../CLAUDE.md`, absolute paths): the registry covers `.claude/` paths only — root files (`CLAUDE.md`, `.mcp.json`, `.claudeignore`) are handled by their own merge paths — so these are reported for the operator to fix rather than sitting silently inert.
68
+
69
+ The classification also consults `.claude/template-modifications.json`, the workspace's registry of deliberate divergences from the template — `{ "localFiles": [...], "modifications": { "<path>": "<reason>" } }` with paths relative to `.claude/` (backslashes and leading `./` or `.claude/` are normalized on read). The template never ships or overwrites this file. `localFiles` are paths or globs the workspace owns outright (never offered by an update); `modifications` records why edited template files are kept as they are — those files still update (three-way merge when both sides changed), the reason just travels with every decision about them. If the output carries `modificationsError`, the registry doesn't parse — STOP: report the error and ask the operator to fix the JSON before any per-file decision. Classification has already failed closed (every `removed` entry is marked `unverifiable: true`, because the file's ownership claims cannot be read), so nothing is offered as a plain removal while it is broken; never invent reasons or exclusions, and after the fix re-run the classifier.
65
70
 
66
71
  Content is compared with line endings normalized (CRLF ≡ LF; binary files byte-exact), so a Windows autocrlf checkout does not read as locally modified.
67
72
 
@@ -102,27 +107,29 @@ Batch the safe cases, ask on the rest:
102
107
  ```bash
103
108
  node {payload}/.claude/scripts/template-merge.mjs --root . --payload {payload} --baseline {baseline}
104
109
  ```
105
- (`{baseline}` resolves as in Step 2; `--files a,b` re-runs a subset, `--out <dir>` redirects the output.) It merges each `differs` file with `git merge-file` — the workspace copy as "local", the staged base as the common ancestor, the payload's copy as "template" — and prints `{ merged: [{path, conflicts: 0, out}], conflicted: [{path, conflicts: N, out}], noBase: [path], errors: [...] }`, writing the merged text under `{payload}/.merged/` mirroring each path. It never writes a workspace file; applying is this skill's job, on approval:
106
- - **Clean merges (`merged`):** show the diff workspace→merged for each file (`git diff --no-index` the workspace file against its `out`), then ONE batched confirmation — "Apply these {N} clean merges? [Y/n]" — listing the files. On yes, copy each `out` file to the same path in the workspace (the update worktree in the remote flow).
107
- - **Conflicts (`conflicted`):** one file at a time. Show the conflict hunks (`<<<<<<< local` … `>>>>>>> template`), propose a resolution, and write the resolved file only after the operator's yes for THAT file. Never resolve a conflict silently, and never delegate resolution to a subagent or any unattended step without the operator approving each file.
108
- - **`noBase`** (no staged base for the path, or its hash does not match the baseline — the base is not this workspace's ancestor): fall back to the two-way ask — "Your version of {file} differs from the template's. Show diff? [y/N]" — then apply, keep, or merge by hand per the user's decision. Expect a previously declined update here: its baseline entry keeps the older version's hash (Step 4's rule), so the newly staged base no longer validates and the file takes this per-file decision.
110
+ (`{baseline}` resolves as in Step 2; `--files a,b` re-runs a subset, `--out <dir>` redirects the output.) It merges each `differs` file with `git merge-file` — the workspace copy as "local", the staged base as the common ancestor, the payload's copy as "template" — and prints `{ merged: [{path, conflicts: 0, out}], conflicted: [{path, conflicts: N, out}], noBase: [path], errors: [...] }`, writing the merged text under `{payload}/.merged/` mirroring each path. Entries in `merged`/`conflicted` carry `reason` when the file is registered in `.claude/template-modifications.json`, and a registered `noBase` path prints as `{ path, reason }` — show that reason at the decision it belongs to. It never writes a workspace file; applying is this skill's job, on approval:
111
+ - **Clean merges (`merged`):** show the diff workspace→merged for each file (`git diff --no-index` the workspace file against its `out`), with its registered reason when it has one, then ONE batched confirmation — "Apply these {N} clean merges? [Y/n]" — listing the files. On yes, copy each `out` file to the same path in the workspace (the update worktree in the remote flow).
112
+ - **Conflicts (`conflicted`):** one file at a time. Show the conflict hunks (`<<<<<<< local` … `>>>>>>> template`) with the file's registered reason when it has one, propose a resolution, and write the resolved file only after the operator's yes for THAT file. Never resolve a conflict silently, and never delegate resolution to a subagent or any unattended step without the operator approving each file.
113
+ - **`noBase`** (no staged base for the path, or its hash does not match the baseline — the base is not this workspace's ancestor): fall back to the two-way ask — "Your version of {file} differs from the template's{ — registered reason}. Show diff? [y/N]" — then apply, keep, or merge by hand per the user's decision. Expect a previously declined update here: its baseline entry keeps the older version's hash (Step 4's rule), so the newly staged base no longer validates and the file takes this per-file decision.
114
+ Whenever the operator's decision KEEPS their local version — a `noBase` "keep", a conflict resolution that keeps the local wording — and the path is not yet registered, offer: "Record why you keep {file} in .claude/template-modifications.json? [y/N]" — on yes, add `"<path>": "<their reason>"` to its `modifications` map (path relative to `.claude/`, creating the file if absent), so the next update shows the reason instead of re-asking blind.
109
115
  When the payload carries no `.template-base/` at all (the CLI could not fetch the installed version — offline or unpublished; the helper reports `templateBase: null`), every `differs` file takes the `noBase` path — say so once in the report.
110
116
  - **Config files (`config`):** `.mcp.json` and `.claude/settings.json` are never copied wholesale — a batch copy wipes the workspace's own MCP servers and settings. Merge each entry key by key (values from `{payload}/{path}` and the workspace's copy): add every `added` key, keep every `workspaceOnly` key untouched, and for each `changed` key ask — "Template changed `{key}` in `{path}`. Take the template's, keep yours, or inspect?" Array-valued keys merge as a union, no ask: keep the workspace's elements in place and append each `arrays` entry's `added` elements (`workspaceOnly` elements are already in place, listed for visibility). `notInstalled` — ask once: "Install {path} from the template? [Y/n]" (never install a config silently); `unparseable` means broken JSON on one side — show the file and ask, never merge blind.
111
117
  - **Local-only edits (`localOnly`):** nothing to decide — these are your local edits to files the template hasn't changed since the last update. List them in the summary (so the edits are visible) and move on; do not ask about them.
112
- - **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list. Restoring installs the payload's version of each.
113
- - **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in `workspace.json` → `workspace.localFiles` instead, showing the exact entry (`"localFiles": ["skills/my-skill/**"]`, or `["rules/my-rule.md"]` for a single file) so future updates skip it.
118
+ - **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list, showing each registered reason (a registered deletion is usually deliberate). Restoring installs the payload's version of each.
119
+ - **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in `.claude/template-modifications.json` → `localFiles` instead, showing the exact entry (`"localFiles": ["skills/my-skill/**"]`, or `["rules/my-rule.md"]` for a single file) so future updates skip it. An entry carrying `reason` shows it with its ask — the registry already says why this file is what it is. An entry carrying `unverifiable: true` means the registry didn't parse and its ownership claims were unreadable — that is Step 2's stop; never offer deletion for one until the JSON is fixed and the classification re-run.
114
120
  - **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
115
121
  - **Stale tests (`staleTests`):** "These {N} test files under .claude/ came from a dev checkout — the package never ships them, so updates can't refresh them (tests live in the template repo). Remove them? [Y/n]" — one confirmation for the whole list.
122
+ - **Template-modification registry (`legacyKeys` + `staleModifications`):** when `legacyKeys` is non-empty, offer once — "Move your localFiles/templateModifications out of workspace.json into `.claude/template-modifications.json`? [Y/n]". On yes, write the registry in the workspace being updated (the update worktree in the remote flow): merge the legacy values into its `localFiles` array and `modifications` map (paths `.claude/`-relative; create the file if absent) and remove the legacy keys from `workspace.json` there — both edits ride the update's commit, so the rest of the team gets the migration with the update. When `staleModifications` is non-empty, offer once — "These {N} modification entries match the template again ({files}, each with its reason shown) — drop them? [Y/n]"; on yes remove those paths from the registry's `modifications` map. `ignoredKeys` entries cover nothing — mention them in the report so the operator can drop or fix them. Never edit the registry without the operator's yes.
116
123
  - **Hook migration (.sh to .mjs):** Detect old `.sh` hooks in `.claude/hooks/` that have `.mjs` replacements in the payload. Offer: "Hook {name}.sh has a .mjs replacement in the update. Replace and update settings.json commands? [Y/n]" — this is a one-time migration for workspaces upgrading from pre-0.2.0
117
124
 
118
125
  Also handle these non-component files from the payload:
119
126
 
120
- - **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
127
+ - **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it — the one exception is the legacy `localFiles`/`templateModifications` keys, which the registry migration above removes with the operator's yes. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
121
128
  - **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, merge — never regenerate from scratch:
122
129
  ```bash
123
130
  node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --merge-claude-md
124
131
  ```
125
- The command prints JSON `{ claudeMd, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
132
+ The command prints JSON `{ claudeMd, droppedIncludes, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `droppedIncludes` names retired `@{file}` includes the workspace still carries but this payload no longer ships — today `@workspace.json`, whose config summary the SessionStart hook now injects instead (gh:196); the line is dropped from the merged result, so name the drop when you show the diff and let the operator's yes (or refusal) on the merged file decide it. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
126
133
  - **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines. An ignore pattern does not untrack already-committed files: if the workspace still tracks the per-machine catalogs the template now ignores (`git ls-files -- 'workspace-context/team-member/*/index.md'`), untrack them (`git rm -r --cached 'workspace-context/team-member/*/index.md'`), or every machine's regenerations keep dirtying pulls.
127
134
 
128
135
  ### Step 4: Update version and write the baseline
@@ -229,13 +236,14 @@ Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules o
229
236
 
230
237
  ### Step 8: Session-model migration nudge
231
238
 
232
- After the update is applied, if the sessions directory (`workspace.workSessionsDir`, default `work-sessions/`) has entries and `workspace.sessionModel` is not `"task"`, append one line to the report: "This workspace still has {N} session(s) under the session model — `/migrate-sessions` can inventory and drain them and switch to the task model whenever you're ready." Suggest only; the operator decides whether and when.
239
+ After the update is applied, read `workspace.json` (Step 4 just rewrote its `templateVersion`, so the file is in hand): if the sessions directory (`workspace.workSessionsDir`, default `work-sessions/`) has entries and `workspace.sessionModel` is not `"task"`, append one line to the report: "This workspace still has {N} session(s) under the session model — `/migrate-sessions` can inventory and drain them and switch to the task model whenever you're ready." Suggest only; the operator decides whether and when.
233
240
 
234
241
  ## Notes
235
242
 
236
243
  - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none, and stages the installed version's template files as `.template-base/` (the merge base `differs` files are merged against) whenever that tarball could be fetched — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull, rebuilds the per-user catalogs the pull may delete, and removes the update worktree with its branch
237
244
  - Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are three-way merged against the staged base when there is one (clean merges batch behind one confirmation, conflicts are resolved one file at a time with the operator's yes, files without a base fall back to the per-file ask); `config` files (`.mcp.json`, `.claude/settings.json`) are merged key by key (array-valued keys union-merged) and never copied wholesale; `localOnly` files are never asked about (local edits to files the template didn't touch)
238
- - Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys, and deliberately activated rules
245
+ - Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys (the legacy `localFiles`/`templateModifications` keys excepted — they migrate to the registry with the operator's yes), and deliberately activated rules
246
+ - `.claude/template-modifications.json` is the workspace's own registry of deliberate divergences — `localFiles` it owns outright (never offered), `modifications` it keeps with a reason (still updated, reason shown at each decision). The template never ships or overwrites the file; this skill reads it, offers to record a reason when the operator keeps an edit, migrates the legacy `workspace.json` keys into it, offers to drop entries that no longer describe the file, and stops on a registry that doesn't parse until the operator fixes the JSON
239
247
  - The template baseline (`.claude/.template-baseline.json`) is what separates `updated`, `differs`, and `localOnly`: entries hold the payload hash of the last-shipped content (unapplied updates keep the older entry), so a deliberately kept local edit stays visible across updates while an untouched file never prompts
240
248
  - The launcher's default branch takes a template-update commit only when the workspace repo has no remote; with ANY remote, the update lands through a task worktree, a branch, and a PR/MR — the default branch is never pushed directly
241
249
  - Can be run multiple times safely (idempotent) — if `.workspace-update/` doesn't exist, it reports no payload and exits
@@ -12,7 +12,8 @@
12
12
  "greeting": "Welcome back to {{project-name}}.",
13
13
  "sessionModel": "session",
14
14
  "tracker": null,
15
- "forge": { "type": "github" }
15
+ "forge": { "type": "github" },
16
+ "mergeApproval": "ask"
16
17
  },
17
18
  "repos": {}
18
19
  }
package/lib/init.test.mjs DELETED
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env node
2
- // Test for init.mjs (--init scaffolding)
3
- // Run: node lib/init.test.mjs
4
- //
5
- // --init stages the template payload, installs the bootstrap skills plus
6
- // hooks/scripts/lib, and must leave a template baseline behind: the sha256 of
7
- // every verbatim payload file, so the first /workspace-update classifies
8
- // three ways instead of treating every template change as a local edit
9
- // (gh:183).
10
- import { initWorkspace } from './init.mjs';
11
- import { createHash } from 'node:crypto';
12
- import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
13
- import { join } from 'path';
14
- import { tmpdir } from 'os';
15
- import { fileURLToPath } from 'url';
16
- import { dirname } from 'path';
17
-
18
- let failed = 0;
19
- let passed = 0;
20
- function check(label, ok) {
21
- if (ok) { passed++; } else {
22
- failed++;
23
- console.error(` FAIL: ${label}`);
24
- }
25
- }
26
-
27
- const here = dirname(fileURLToPath(import.meta.url));
28
- const pkgVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
29
- const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
30
-
31
- const root = mkdtempSync(join(tmpdir(), 'init-test-'));
32
-
33
- try {
34
- await initWorkspace(root);
35
-
36
- const baselinePath = join(root, '.claude', '.template-baseline.json');
37
- check('baseline written at .claude/.template-baseline.json', existsSync(baselinePath));
38
- const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
39
-
40
- check(`baseline records the payload version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
41
- check(
42
- 'baseline covers the bootstrap skills --init installs',
43
- typeof baseline.files['.claude/skills/workspace-update/SKILL.md'] === 'string'
44
- && typeof baseline.files['.claude/skills/workspace-init/SKILL.md'] === 'string',
45
- );
46
- check(
47
- 'baseline covers hooks and scripts',
48
- typeof baseline.files['.claude/hooks/session-start.mjs'] === 'string'
49
- && typeof baseline.files['.claude/scripts/classify-update.mjs'] === 'string',
50
- );
51
- check(
52
- 'baseline covers the standalone verbatim files',
53
- typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
54
- );
55
- check(
56
- 'entry hash matches the installed file byte-for-byte',
57
- baseline.files['.claude/scripts/classify-update.mjs'] === sha(join(root, '.claude', 'scripts', 'classify-update.mjs')),
58
- );
59
- check(
60
- 'baseline never covers tests (the tarball ships none)',
61
- Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs')),
62
- );
63
- check(
64
- 'baseline never covers machine-local files',
65
- !('.claude/settings.local.json' in baseline.files) && !('.claude/.active-session.json' in baseline.files),
66
- );
67
- } finally {
68
- rmSync(root, { recursive: true, force: true });
69
- }
70
-
71
- if (failed > 0) {
72
- console.error(`${failed} check(s) failed, ${passed} passed`);
73
- process.exit(1);
74
- }
75
- console.log(`${passed} checks passed`);