@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.
- package/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +43 -0
- package/template/_claude/hooks/session-start.mjs +10 -0
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- 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"`
|
|
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
|
|
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
|
|
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
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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"`
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
|
45
|
-
- **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The
|
|
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
|
|
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/`,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
56
|
+
**Step 5: Leak audit (optional)**
|
|
57
57
|
|
|
58
|
-
Pull the merge,
|
|
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
|
|
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
|
|
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:
|
|
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 }`
|
|
31
|
-
- **Adopt into this chat** — record
|
|
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
|
|