@ulysses-ai/create-workspace 0.23.2-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 (36) 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/migrate-sessions.mjs +212 -34
  15. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  16. package/template/_claude/scripts/task-pr.mjs +316 -72
  17. package/template/_claude/scripts/task-worktree.mjs +9 -1
  18. package/template/_claude/scripts/template-merge.mjs +21 -4
  19. package/template/_claude/scripts/template-modifications.mjs +152 -0
  20. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  21. package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
  22. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  23. package/template/_claude/skills/braindump/SKILL.md +1 -1
  24. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  25. package/template/_claude/skills/handoff/SKILL.md +1 -1
  26. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  27. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
  28. package/template/_claude/skills/release/SKILL.md +20 -6
  29. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  30. package/template/_claude/skills/start-work/SKILL.md +23 -5
  31. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  32. package/template/workspace.json.tmpl +2 -1
  33. package/lib/init.test.mjs +0 -75
  34. package/lib/payload.test.mjs +0 -210
  35. package/lib/scaffold.test.mjs +0 -79
  36. package/lib/upgrade.test.mjs +0 -547
@@ -8,6 +8,39 @@
8
8
  // defaults to) this adapter's own repo, "owner/repo#N" otherwise —
9
9
  // only the first form closes an issue in the PR's own repo, so a PR
10
10
  // in any other repo must name the tracker's repo explicitly.
11
+ //
12
+ // Epic method contract (gh:195) — every adapter implements all five:
13
+ //
14
+ // listEpics({ state } = { state: 'open' }) → Epic[] — { name, id?,
15
+ // native, url?, state? }. `id`, `state` and `groupId` only exist in
16
+ // native mode; a closed GitLab epic is filtered out unless state is
17
+ // "all". Label mode has no epic state and ignores the option.
18
+ // getEpic(name) → the Epic, or null when absent. Names match
19
+ // case-insensitively — both forges treat labels that way — and the
20
+ // returned name is the label's own casing.
21
+ // createEpic({ name, description? }) → the Epic, creating only when no
22
+ // epic of that name exists in any casing (idempotent) — epics are
23
+ // created deliberately, never as a side effect of assigning an issue.
24
+ // setIssueEpic(issueId, name | null)
25
+ // An issue carries exactly one epic: assigning replaces any current
26
+ // epic, null removes it. An unknown name throws — a typo must never
27
+ // mint an epic.
28
+ // listEpicIssues(name, { state }) → Issue[], state "open" | "closed" |
29
+ // "all" (default open; anything else throws).
30
+ //
31
+ // Modes: label mode (default, both adapters) represents an epic as the
32
+ // label `epicLabelPrefix + name` — default prefix "epic:", configurable
33
+ // via workspace.tracker.epicLabelPrefix, which must end with a delimiter
34
+ // (resolveEpicLabelPrefix enforces that) — carried by every issue in the
35
+ // epic. Native mode (GitLab only, workspace.tracker.epics: "native") uses
36
+ // group epics and needs Premium/Ultimate; without it the group-epic
37
+ // endpoints answer 403/404, which must surface as a clear error pointing
38
+ // at label mode — never a silent fallback, or epics would silently live
39
+ // in two places. Epic iids are unique only within their group, so native
40
+ // epics are pinned to the tracker group's own (descendant and ancestor
41
+ // groups excluded) and matched by group and iid together. GitHub has no
42
+ // native epic object: `epics: "native"` throws from every epic method
43
+ // there (sub-issues may back one later).
11
44
 
12
45
  import '../../lib/require-node.mjs';
13
46
  import { createGithubAdapter } from './github-issues.mjs';
@@ -22,6 +55,20 @@ export class AlreadyAssignedError extends Error {
22
55
  }
23
56
  }
24
57
 
58
+ // Shared epic-label-prefix resolution (gh:195): `epicLabelPrefix` from the
59
+ // tracker config, defaulting to "epic:". A prefix must end with a delimiter
60
+ // (":", "-", "/") — a trailing alphanumeric would slice epic names at an
61
+ // arbitrary character and make label↔name mapping ambiguous.
62
+ export function resolveEpicLabelPrefix(config) {
63
+ const prefix = typeof config?.epicLabelPrefix === 'string' && config.epicLabelPrefix
64
+ ? config.epicLabelPrefix
65
+ : 'epic:';
66
+ if (/[a-z0-9]$/i.test(prefix)) {
67
+ throw new Error(`tracker.epicLabelPrefix "${prefix}" must end with a delimiter such as ":" or "-"`);
68
+ }
69
+ return prefix;
70
+ }
71
+
25
72
  export function createTracker(config, options = {}) {
26
73
  if (!config || typeof config !== 'object') {
27
74
  throw new Error('No tracker configured — pass workspace.json\'s workspace.tracker block.');
@@ -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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: migrate-sessions
3
- description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script never deletes anything.
3
+ description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script deletes nothing but verified-empty orphan shells.
4
4
  ---
5
5
 
6
6
  # Migrate Sessions
@@ -40,11 +40,11 @@ For each session, lay out its evidence and ask the operator which way to go. Nev
40
40
  ```bash
41
41
  node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
42
42
  ```
43
- With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` refuses while any tip holds commits no remote backs, and proceeds only with `--allow-unbacked`, the operator's recorded no after seeing the counts.
44
- 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when a remote holds the tips (a pushed backup), or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move), and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
45
- - **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The command re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
43
+ With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` counts what this step pushed (a tip is backed when a remote holds it on a branch or under a `drain/{session}/*` tag), so `--allow-unbacked` is needed only when the operator declined the backup — pass it only on their explicit yes naming the session, after showing them the counts.
44
+ 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted — the result names them (`heldBranches`). The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs on a branch or under a pushed `drain/{session}/*` tag — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when the backup pushed the tips somewhere a remote holds them, or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move; a drain-tag check that did not clear, with the reason — an unreachable remote, a session name the pattern cannot express), report tips the result lists under `backedByTag` as backed by their drain tag, and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
45
+ - **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The scripted removal re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
46
46
  ```bash
47
- node -e "const fs=require('fs');const p=process.argv[1];const empty=d=>fs.readdirSync(d,{withFileTypes:true}).every(e=>e.isDirectory()&&empty(d+'/'+e.name));if(!empty(p)){console.error(p+' is not empty — left alone');process.exit(1)}fs.rmSync(p,{recursive:true});console.log('removed empty shell '+p)" work-sessions/{name}
47
+ node .claude/scripts/migrate-sessions.mjs --remove-shell --session {name}
48
48
  ```
49
49
  - **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle. A kept session cannot be converted to a task in place — no converter exists, and the lifecycles keep their state differently (a session folder with a tracker vs. a branch with a chat-record entry). The supported equivalent: finish the session (merge it) and start the remaining work as a task, or keep it under the session lifecycle until it is done. Do not improvise a conversion by hand.
50
50
 
@@ -58,26 +58,33 @@ The switch procedure:
58
58
  ```bash
59
59
  node .claude/scripts/task-worktree.mjs --root . --create --repo . --branch chore/enable-task-model
60
60
  ```
61
- 2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`):
61
+ 2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`), passing the launcher so the remaining-session count comes back with the result:
62
62
  ```bash
63
- node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model
63
+ node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model --launcher .
64
64
  ```
65
- 3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, then create, ask before merging, and pull the launcher after the merge:
65
+ 3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, and create the PR:
66
66
  ```bash
67
67
  node .claude/scripts/task-pr.mjs --create --root . --branch chore/enable-task-model \
68
68
  --repo . --body-file .=workspace-scratchpad/switch-pr.md --out workspace-scratchpad/switch-prs.json
69
- node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
70
69
  ```
71
- Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree, fast-forward the launcher's default branch, remove the worktree:
70
+ Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree and leave the landing to step 4:
72
71
  ```bash
73
72
  git -C .claude/worktrees/chore-enable-task-model add workspace.json
74
73
  git -C .claude/worktrees/chore-enable-task-model commit -m "chore: switch to the task lifecycle"
75
- git -C . merge --ff-only chore/enable-task-model
76
- node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
77
74
  ```
75
+ 4. **Ask the operator before merging** — a step of its own, not a clause inside step 3, because a one-line diff is still the launcher's default branch and "it's only a one-line change" is not permission. Ask exactly: "The task-model switch is ready to merge into the launcher's default branch. Merge it now?" Only an explicit yes merges; on yes:
76
+ - forge remote — merge the PR, then pull the launcher:
77
+ ```bash
78
+ node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
79
+ ```
80
+ - no remote — fast-forward the launcher's default branch, then remove the worktree:
81
+ ```bash
82
+ git -C . merge --ff-only chore/enable-task-model
83
+ node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
84
+ ```
78
85
  Either way the launcher root never commits to its default branch directly. A workspace with neither a remote nor a tracker can use the task model's local mode (gh:173); until that ships, recommend such workspaces stay on sessions.
79
86
 
80
- The switch output reports `remainingSessions: null` when run from the worktree — the real remaining-sessions list comes from a separate `--inventory` at the launcher root. Remaining sessions are fine either way: they keep resuming and completing under the session lifecycle after the switch.
87
+ With `--launcher .` the switch output reports `remainingSessions` from the launcher even though `--root` is the switch worktree (which has no `work-sessions/` to read; without the flag the result is `remainingSessions: null` with a note pointing at a separate `--inventory` at the launcher root). Remaining sessions are fine either way: they keep resuming and completing under the session lifecycle after the switch.
81
88
 
82
89
  ## 4. Verify
83
90
 
@@ -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