@ulysses-ai/create-workspace 0.19.0-beta.0 → 0.20.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.
@@ -368,16 +368,17 @@ Ask: "These changes weren't part of a formal work session. What do you want to d
368
368
  Reached from Step 1 when detection says `model: task`. The state is the branch, the chat record's task entries, and the linked issue — there is no session folder and no `session.md`. This chat normally runs at the workspace root (the launcher) but may be running inside one of the worktrees; either way, every input comes from the detect result, never from cwd. Run `cd "{launcher-root}"` first — steps 2–5 and every relative path in them are anchored there:
369
369
 
370
370
  - `{chat}` — the `Chat record:` line injected by the SessionStart hook (the same value Step 1 passed as `--chat`)
371
- - `{tasks}` — the detect result's task entries from the chat record, each carrying `{workItem}`, `{branch}`, `{repo}`; `repo: "."` is the workspace repo itself. When detection came from cwd alone (`source: 'worktree'`, no chat record entry — e.g. a no-tracker task) there are no task entries: take `{repo}` and `{branch}` from the detect result itself and treat `{workItem}` as absent
371
+ - `{tasks}` — the detect result's task entries from the chat record, each carrying `{workItem}` (null without a tracker), `{branch}`, `{repo}`; `repo: "."` is the workspace repo itself. When detection came from cwd alone (`source: 'worktree'`, no chat record entry — e.g. a chat whose record was never written) there are no task entries: take `{repo}` and `{branch}` from the detect result itself and treat `{workItem}` as absent
372
372
  - `{worktree}` — `{launcher-root}/repos/{repo}/.claude/worktrees/{slug}` for a project repo, or `{launcher-root}/.claude/worktrees/{slug}` for the workspace repo (`.`), where `{slug}` is the branch with `/` replaced by `-`
373
373
  - `{workspace-worktree}` — `{launcher-root}/.claude/worktrees/{slug}`: the workspace repo's own task worktree, created in step 2 only when drawer items are promoted (Claude Code's native worktree location — the two converge on it)
374
- - `{defaultBranch}` — `workspace.json` → `repos.{repo}.branch`, default `main`; for `.` it is the workspace origin's HEAD with `main` as the fallback — exactly what `task-worktree.mjs`'s `defaultBranchFor` resolves
374
+ - `{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
375
+ - `{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.
375
376
 
376
377
  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.
377
378
 
378
379
  0. **Pre-flight: the worktrees must be clean.** For each `{worktree}` of the chosen task, `git -C "{worktree}" status --porcelain` must be empty. If it is not, stop here — before the rebase — and ask the user to commit or discard: rebasing over uncommitted work silently invalidates it.
379
380
 
380
- 1. **Rebase each task worktree onto `origin/{defaultBranch}`** (`git -C "{worktree}" fetch origin`, then `git -C "{worktree}" rebase "origin/{defaultBranch}"`). Freshness first: the PR in step 3 must describe the branch as it will merge. If conflicts arise, STOP and present them — do not auto-resolve.
381
+ 1. **Rebase each task worktree onto its base** — `origin/{defaultBranch}` for a `forge` repo (`git -C "{worktree}" fetch origin`, then `git -C "{worktree}" rebase "origin/{defaultBranch}"`), the local `{defaultBranch}` for a `local` repo (`git -C "{worktree}" rebase "{defaultBranch}"`; no fetch — there is no remote to fetch from). Freshness first: the PR in step 3 must describe the branch as it will merge. If conflicts arise, STOP and present them — do not auto-resolve.
381
382
 
382
383
  2. **Route durable thinking.** List the chat drawer `{launcher-root}/workspace-scratchpad/chats/{chat}/` and ask which items should graduate into `workspace-context/`. The offer is filtered by task: an item whose frontmatter carries a `workItem:` is listed only when it names this task's `{workItem}`, an item with no `workItem:` is always listed, and an item tagged for another open task is not this completion's to promote — it belongs to that task's completion. Leftovers of an earlier completion attempt are never offered: the `pr-{slug}-*.md` bodies and the `prs-{slug}.json` state file are step 3–4 machinery, not thinking. Drafting skills (`/braindump`, `/handoff`, designs and plans) tag their drawer writes with `workItem:` while a task is active so this filter has something to filter on. If nothing is chosen, skip this step — no `.` worktree is needed yet. Do NOT invoke `/promote`: it writes relative to cwd and commits per item, which at the launcher lands on the default branch — exactly the hole this flow closes, and it does not know about drawer items. For the chosen items, first create the workspace worktree on the task's branch and record it alongside the project entries:
383
384
 
@@ -386,7 +387,7 @@ If several tasks are open, ask the user which one to complete — group by branc
386
387
  node "{launcher-root}/.claude/scripts/chat-record.mjs" --root "{launcher-root}" --add-task --chat "{chat}" --work-item "{workItem}" --branch "{branch}" --repo "."
387
388
  ```
388
389
 
389
- Skip the record line when there is no `{workItem}`. Then copy each chosen item to `{workspace-worktree}/workspace-context/shared/{item}` or `{workspace-worktree}/workspace-context/team-member/{user}/{item}` — ask which level; `shared/locked/` only if explicitly requested — keeping the filename and making sure the file carries a `description:` frontmatter (add one if the drawer item has none). Never write under `{launcher-root}/workspace-context/` at the launcher. Rebuild the indexes and commit once, and only when something is staged:
390
+ Omit `--work-item` from the record line when the task has none — without a work item the entry is keyed by repo + branch. Then copy each chosen item to `{workspace-worktree}/workspace-context/shared/{item}` or `{workspace-worktree}/workspace-context/team-member/{user}/{item}` — ask which level; `shared/locked/` only if explicitly requested — keeping the filename and making sure the file carries a `description:` frontmatter (add one if the drawer item has none). Never write under `{launcher-root}/workspace-context/` at the launcher. Rebuild the indexes and commit once, and only when something is staged:
390
391
 
391
392
  ```bash
392
393
  node "{launcher-root}/.claude/scripts/build-workspace-context.mjs" --write --root "{workspace-worktree}"
@@ -396,7 +397,7 @@ If several tasks are open, ask the user which one to complete — group by branc
396
397
 
397
398
  The drawer is per-chat, so it survives task teardown — but it is machine-local and backed up nowhere, and this review, with the work fresh in mind, is the moment to decide what graduates. Items left behind are not lost, only unreviewed.
398
399
 
399
- 3. **Write one PR body per repo into the drawer, then create every PR with `task-pr.mjs --create`** — never `gh pr` directly. For each repo of the task, `{worktree}` included when it exists, write `{launcher-root}/workspace-scratchpad/chats/{chat}/pr-{slug}-{repo}.md` (with `{repo}` rendered as `workspace` for `.`). Each body carries a short summary of what changed and why, then a `## Verification` section stating how the change was checked — the commands run and their results. The drawer is machine-local and gitignored, so these files never touch a branch, and `--create` requires one for every repo whose branch has commits. Then one command does the rest:
400
+ 3. **Write one PR body per forge repo into the drawer, then create every PR with `task-pr.mjs --create`** — never `gh pr` directly. For each **forge**-mode repo of the task, `{workspace-worktree}` included when it exists, write `{launcher-root}/workspace-scratchpad/chats/{chat}/pr-{slug}-{repo}.md` (with `{repo}` rendered as `workspace` for `.`). Each body carries a short summary of what changed and why, then a `## Verification` section stating how the change was checked — the commands run and their results. The drawer is machine-local and gitignored, so these files never touch a branch; `--create` requires one for every forge repo whose branch has commits, and a local repo needs none (one given for it is accepted and ignored). Then one command does the rest:
400
401
 
401
402
  ```bash
402
403
  node "{launcher-root}/.claude/scripts/task-pr.mjs" --create --root "{launcher-root}" --branch "{branch}" \
@@ -405,9 +406,9 @@ If several tasks are open, ask the user which one to complete — group by branc
405
406
  --out "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json"
406
407
  ```
407
408
 
408
- Omit `--work-item` when the task has none, and repeat `--body-file` once per 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 skips repos whose branch has no commits over `origin/{defaultBranch}` (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), parses each worktree's own origin into `{owner}/{name}` and stops **before pushing anything** if any origin is not forge-hosted — such a repo is completed under the session model — then pushes `-u origin {branch}` and opens one PR per remaining repo through a per-repo forge aimed at that worktree's own remote, never the launcher's. The workspace repo (`.`) is handled exactly like a project repo. 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`, 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, owner, name, number, id, url, isWorkspace }], empty: [repo…], pushed: [repo…] }`, printed to stdout as well — instead of shell redirection, so the command is identical on every shell. 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.
409
+ 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.
409
410
 
410
- 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 a PR — the workspace repo included — built from `--create`'s output, and ask once:
411
+ 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?`:
411
412
 
412
413
  ```
413
414
  Task complete:
@@ -415,9 +416,12 @@ If several tasks are open, ask the user which one to complete — group by branc
415
416
  PROJECT: {owner}/{name}
416
417
  PR: {pr.url}
417
418
  Branch: {branch} → {defaultBranch}
418
- Commits: {n} # git -C "{worktree}" rev-list --count "origin/{defaultBranch}..{branch}"
419
+ Commits: {n} # from the entry's `commits` count
419
420
 
420
- WORKSPACE: {ws-owner}/{ws-name} # from the workspace worktree's origin
421
+ PROJECT: {repo} (local merge)
422
+ Branch: {branch} → {defaultBranch} # merged in repos/{repo}
423
+
424
+ WORKSPACE: {ws-owner}/{ws-name} # from the workspace worktree's origin, or "(local merge)"
421
425
  PR: {ws-pr.url}
422
426
  Branch: {branch} → {defaultBranch}
423
427
 
@@ -433,16 +437,16 @@ If several tasks are open, ask the user which one to complete — group by branc
433
437
  --prs "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json" --work-item "{workItem}"
434
438
  ```
435
439
 
436
- The script checks each PR's state first, then merges the project PRs (squash, delete branch), then the workspace PR 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 counts as done and is never merged twice, which is what makes a re-run safe: the run that finally gets every PR merged also does the pull and the close. The launcher is pulled `--ff-only` only when it sits on the workspace default branch — it waited there on the workspace merge, and a project-only task pulls after the project merges; when it sits on some other branch the JSON 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 — in both cases tell the user to pull the launcher by hand before step 5. The close comes last, with a one-line comment naming the merged PR URL(s): merge precedes close because an issue closed before its PR merges points at work that never landed; without a `{workItem}` (no tracker, or the task was never recorded) the close is skipped and the script says so. On any merge failure the script stops, names the PRs 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`.
440
+ 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`.
437
441
 
438
- 5. **Tear down only what finished — worktree first, then the record entry**, and only for a repo whose PR merged 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.
442
+ 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.
439
443
 
440
444
  ```bash
441
445
  node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}" --remove --repo "{repo}" --branch "{branch}" --delete-branch
442
446
  node "{launcher-root}/.claude/scripts/chat-record.mjs" --root "{launcher-root}" --remove-task --chat "{chat}" --work-item "{workItem}" --repo "{repo}"
443
447
  ```
444
448
 
445
- Skip the `--remove-task` line when there is no record entry (no `{workItem}`). 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.
449
+ 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.
446
450
 
447
451
  ## Notes
448
452
  - The session tracker's body is the primary source for PR-body synthesis — it captures the full session history alongside specs and plans
@@ -17,16 +17,21 @@ Drain a workspace's accumulated session entries and switch new work to the task
17
17
  node .claude/scripts/migrate-sessions.mjs --inventory
18
18
  ```
19
19
 
20
- Read-only. Present the stderr table plus each session's proposal with its reasons and warnings. Say plainly that the proposals are proposals — evidence and a starting point, not decisions. Pay particular attention to the per-remote state shown per worktree (`same`, `ahead +N`, `behind -N`, `diverged +N/-M`, `not-fetched`, `unknown`) and to `unbacked` warnings: they change what Finish and Archive mean for that session. Entries shown as `foreign` (symlinked) are never acted on — surface them for manual reconciliation.
20
+ Read-only. Present the stderr table plus each session's proposal with its reasons and warnings. Say plainly that the proposals are proposals — evidence and a starting point, not decisions. Pay particular attention to the per-remote state shown per worktree (`same`, `ahead +N`, `behind -N`, `diverged +N/-M`, `not-fetched`, `unknown`) and to `unbacked` warnings: they change what Finish and Archive mean for that session. Each worktree also lists every configured remote with its exact URL — read those before any backup decision: `origin:none` means only that the remote holds no copy of this branch, never that the repo has no remote, and an `origin` that is really a third-party upstream shows its URL right there. Entries shown as `foreign` (symlinked) are never acted on — surface them for manual reconciliation.
21
21
 
22
22
  ## 2. Decide per session, with the operator — one at a time
23
23
 
24
24
  For each session, lay out its evidence and ask the operator which way to go. Never infer the decision from the proposal. The options:
25
25
 
26
26
  - **Finish** (typical for MERGEABLE) — resume the session with `/start-work`, then run `/complete-work`; its own merge confirmation applies there. But if the inventory shows a **diverged** remote for that session, say so *before* the operator chooses Finish: `/complete-work`'s plain push will be rejected, and pushing the rewritten history needs `--force-with-lease` — which you run only on the operator's explicit yes naming the branch. Never force silently.
27
- - **Archive** (typical for ABANDONED, a broken shell, or a MERGEABLE the operator gives up on) — take it out of the active lifecycle without destroying anything. Two steps, each its own decision:
28
- 1. **Offer a backup.** Archiving keeps everything on this machine; a backup adds an off-machine copy of the session's commits, and it is what makes a later deletion safe. It creates `drain/{session}/…` tags and pushes them to the resolved remote(s) — tags may land in a public repository, so show the plan first: `node .claude/scripts/migrate-sessions.mjs --backup --session {name} --dry-run` (add `--remote <name>` to aim somewhere other than the resolved default). It lists, per tip, the tag, the remote, and which tips a remote branch or tag already holds exactly, with no side effects. On the operator's yes, run it without `--dry-run` and show the tags it created. Declining is fine — the archive still keeps everything locally. A repo with no remote at all is refused by backup; say so.
29
- 2. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}`. 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, or when it holds a submodule checkout (its link cannot be repaired) — surface the 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).
27
+ - **Archive** (typical for ABANDONED, a broken shell, or a MERGEABLE the operator gives up on) — take it out of the active lifecycle without destroying anything. Three steps, in this order, each its own decision:
28
+ 1. **Clear uncommitted work.** `--archive` refuses when any of the session's worktrees has uncommitted or untracked changes — an edited `session.md` counts — and names them, because edits buried uncommitted in an archive are invisible to every later merge or PR. Offer the operator: commit them to the session branch first (`git -C {worktree} add -A`, then `git -C {worktree} commit -m "…"` — per dirty worktree), discard them explicitly (`git -C {worktree} restore …` / `git -C {worktree} clean …`), or, on an explicit yes, re-run with `--allow-uncommitted` to archive them mid-edit. Do this before the backup: the backup tags committed tips only, so committing first brings those edits under the backup, while anything archived with `--allow-uncommitted` is NOT in it.
29
+ 2. **Offer a backup.** Archiving keeps everything on this machine; a backup adds an off-machine copy of the session's commits, and it is what makes a later deletion safe. It covers the committed tips as they stand after step 1. Plain `--backup` creates `drain/{session}/…` tags locally and pushes nothing — for a repo whose only remote is one the operator does not own, that local tag IS the backup. Pushing is a separate, explicitly allowed step:
30
+ ```bash
31
+ node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
32
+ ```
33
+ 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 is fine — the archive still keeps everything locally.
34
+ 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}`. 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, or when it holds a submodule checkout (its link cannot be repaired) — surface the 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).
30
35
  - **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle.
31
36
 
32
37
  Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one. Say so when you archive such a session, and offer the backup.
@@ -43,7 +48,14 @@ The switch procedure:
43
48
  ```bash
44
49
  node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model
45
50
  ```
46
- 3. Commit there, open a PR through the workspace's normal flow, and pull the launcher after merge. The launcher root never commits to its default branch.
51
+ 3. Land the change. With a forge-hosted remote, commit there, open a PR through the workspace's normal flow, and pull the launcher after merge. Without one (`git -C . remote -v` shows no remote this workspace can open PRs against), land it locally — commit in the worktree, fast-forward the launcher's default branch, remove the worktree:
52
+ ```bash
53
+ git -C .claude/worktrees/chore-enable-task-model add workspace.json
54
+ git -C .claude/worktrees/chore-enable-task-model commit -m "chore: switch to the task lifecycle"
55
+ git -C . merge --ff-only chore/enable-task-model
56
+ node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
57
+ ```
58
+ 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.
47
59
 
48
60
  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.
49
61
 
@@ -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. Tell the user plainly what that costs: without a tracker there is no `workItem`, the task is not recorded on the chat record, and `/complete-work` cannot find it from the launcher. It is completed either by running `/complete-work` from inside the worktree (cwd detection) or by opening the PR by hand.
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.
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`), fetch it with `tracker.getIssue(id)`, 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. Otherwise, list the candidates — the same adapter calls as Flow: Blank steps 3–6:
25
25
 
@@ -54,13 +54,13 @@ If `workspace.tracker` is absent, say tracking is off and skip step 1 — but st
54
54
  ```bash
55
55
  node .claude/scripts/task-worktree.mjs --root . --create --repo "{repo}" --branch "{branch}"
56
56
  ```
57
- The script fetches origin best-effort (offline is fine) before choosing the base. A project repo's worktree lands at `repos/{repo}/.claude/worktrees/{slug}/` — Claude Code's native worktree location — based on `origin/{defaultBranch}` when that ref exists, and never tracking it. For `.` the worktree lands at `.claude/worktrees/{slug}/` — the same native location, one level up — based on the workspace origin's HEAD (falling back to `main`); the workspace's own `.gitignore` already covers the path.
57
+ The script fetches origin best-effort (offline is fine) before choosing the base. A project repo's worktree lands at `repos/{repo}/.claude/worktrees/{slug}/` — Claude Code's native worktree location — based on `origin/{defaultBranch}` when that ref exists and the local `{defaultBranch}` otherwise — a local-mode repo (`merge: "local"`) bases on the local `{defaultBranch}` itself, since its merges land there and its origin ref never advances — and never tracking it. For `.` the worktree lands at `.claude/worktrees/{slug}/` — the same native location, one level up — based on the workspace origin's HEAD (falling back to `main`); the workspace's own `.gitignore` already covers the path. A repo without a forge remote — no origin at all, or `merge: "local"` in `workspace.json` — runs the task in local mode: nothing is pushed for it, and `/complete-work` merges it into its source clone instead.
58
58
 
59
- 5. **Record the task on this chat's record** (only when a `workItem` exists — see the no-tracker note above):
59
+ 5. **Record the task on this chat's record:**
60
60
  ```bash
61
61
  node .claude/scripts/chat-record.mjs --root . --add-task --chat "{chat}" --work-item "{workItem}" --branch "{branch}" --repo "{repo}"
62
62
  ```
63
- `{chat}` is the name from the `Chat record:` line the SessionStart hook injected into this conversation. If there is no such line, run `node .claude/scripts/chat-record.mjs --whoami --root .` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id. When that too prints nothing, say so and skip recording rather than guessing a name.
63
+ Omit `--work-item` when the task has none — the entry is recorded with a null work item, keyed by repo + branch. `{chat}` is the name from the `Chat record:` line the SessionStart hook injected into this conversation. If there is no such line, run `node .claude/scripts/chat-record.mjs --whoami --root .` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id. When that too prints nothing, say so and skip recording rather than guessing a name.
64
64
 
65
65
  6. **Tell the user where the work happens:** the worktree path(s) above — edits belong there, not in the source clones at `repos/{repo}/`. Work continues from this chat by path. A chat started inside a **project** worktree would not load the workspace's conventions or hooks (a worktree is a context boundary), so staying here is the default. A `.` worktree does load a copy of the workspace's `CLAUDE.md`/`.claude/` — but with the worktree as root, so its chat records land in the worktree's own scratchpad rather than the launcher's; the task still belongs to this chat.
66
66
 
@@ -26,11 +26,11 @@ If `workspace.json` has `"initialized": true` and no `.workspace-update/` payloa
26
26
 
27
27
  ## Branching
28
28
 
29
- Workspace-init creates a branch for all its work:
29
+ Workspace-init creates a branch for all its work. Record the default branch first (`git branch --show-current` before switching — usually `main`), then:
30
30
  ```bash
31
31
  git checkout -b chore/workspace-init
32
32
  ```
33
- All commits go on this branch. After completion, the user reviews and squash-merges to main.
33
+ All commits go on this branch. Step 18 merges the branch back into the default branch at the end — init must finish with every commit on the default branch, never stranded on the init branch (an unmerged init branch leaves `workspace.json` without `initialized: true` on the default branch, which blocks later `/workspace-update` runs). The granular per-step history stays on `chore/workspace-init` for review.
34
34
 
35
35
  ## Flow
36
36
 
@@ -373,9 +373,9 @@ git remote -v
373
373
  - Detect the org from project repo remotes in workspace.json
374
374
  - Ask: "Create workspace repo as `{org}/workspace-{project}`? Or provide a different name/URL."
375
375
  - Create via `gh repo create {org}/{name} --private` and add as remote
376
- - Do NOT push yet — user merges the branch first
376
+ - Do NOT push yet — Step 18 merges the init branch and pushes the default branch after the merge
377
377
 
378
- ### Step 18: Mark initialized and report
378
+ ### Step 18: Mark initialized, merge to the default branch, report
379
379
 
380
380
  Update workspace.json:
381
381
  - Set `initialized: true`
@@ -383,12 +383,20 @@ Update workspace.json:
383
383
 
384
384
  **Commit:** `git commit -m "chore: mark workspace as initialized"`
385
385
 
386
+ **Merge to the default branch.** Init is not finished while its commits live only on `chore/workspace-init` — an unmerged init branch leaves the default branch without `initialized: true`, which blocks later `/workspace-update` runs. Ask: "Merge chore/workspace-init into {default-branch} now? [Y/n]" and on yes:
387
+ ```bash
388
+ git checkout {default-branch}
389
+ git merge --squash chore/workspace-init
390
+ git commit -m "chore: workspace initialization"
391
+ ```
392
+ If a remote is configured, push the default branch (`git push origin {default-branch}`; use `--force-with-lease` only if the operator explicitly accepts rewritten history after a Step 17 rebase). Keep `chore/workspace-init` — it carries the granular per-step history — unless the user asks for it to be deleted. If the user declines the merge, the final report must tell them exactly how to finish it themselves.
393
+
386
394
  **Final report:**
387
395
 
388
396
  ```
389
397
  "Workspace initialized. Restart Claude Code for all rules and hooks to take effect. Then run /start-work to begin.
390
398
 
391
- Branch: chore/workspace-init
399
+ Branch: chore/workspace-init (merged to {default-branch} as "chore: workspace initialization")
392
400
 
393
401
  Summary:
394
402
  - {N} repos cloned
@@ -402,6 +410,7 @@ Summary:
402
410
  - {V} self-contradictions found and fixed
403
411
  - Template version: {version}
404
412
  - Remote: {status}
413
+ - All init commits are on {default-branch}
405
414
 
406
415
  Issues encountered:
407
416
  - {list every expected behavior that failed}
@@ -414,15 +423,12 @@ Active work sessions (formalized from existing worktrees):
414
423
  Items in workspace-scratchpad/unmigrated/:
415
424
  - {list each item with a one-line description}
416
425
 
417
- Review the branch:
418
- git log --oneline chore/workspace-init
419
- git diff main..chore/workspace-init
420
-
421
- Then merge:
422
- git checkout main
423
- git merge --squash chore/workspace-init
424
- git commit -m 'chore: workspace initialization'
425
- git push origin main
426
+ If the merge was declined, replace the branch line with:
427
+ Not merged yet — finish with:
428
+ git checkout {default-branch}
429
+ git merge --squash chore/workspace-init
430
+ git commit -m 'chore: workspace initialization'
431
+ git push origin {default-branch}
426
432
 
427
433
  This session is done. Start a fresh Claude Code session and run /start-work to begin."
428
434
  ```
@@ -10,10 +10,14 @@ Apply a staged template update to an initialized workspace. The CLI (`npx @ulyss
10
10
  ## Prerequisites
11
11
 
12
12
  - `workspace.json` must have `initialized: true`
13
- - If not initialized, report: "Workspace not initialized. Run /workspace-init first."
13
+ - If not initialized, check whether initialization was committed but never merged — the workspace-init flow ends with its commits merged to the default branch, so an unmerged init branch explains a missing flag:
14
+ ```bash
15
+ git log --all --format=%H -S'"initialized": true' -- workspace.json
16
+ ```
17
+ If there are hits, name the branch holding the newest commit (`git branch --all --contains {sha}`) and report: "This workspace was initialized on branch `{branch}`, but that branch was never merged. Merge it first (`git merge {branch}`), then re-run /workspace-update." Only if there are no hits, report: "Workspace not initialized. Run /workspace-init first."
14
18
  - `.workspace-update/` payload directory must exist (staged by `npx @ulysses-ai/create-workspace --upgrade`)
15
19
  - If no `.workspace-update/` payload exists, report: "No update payload found. Run `npx @ulysses-ai/create-workspace --upgrade` to stage the template."
16
- - Read `.workspace-update/.manifest.json` for `fromVersion`, `toVersion`, and `action`
20
+ - Read `.workspace-update/.manifest.json` for `fromVersion`, `templateVersion` (the target version), and `action`
17
21
  - If `action` is `"init"`, report: "This payload is for initial setup. Run /workspace-init instead."
18
22
 
19
23
  ## Flow
@@ -22,21 +26,43 @@ Apply a staged template update to an initialized workspace. The CLI (`npx @ulyss
22
26
 
23
27
  Run `/maintenance audit` (read-only) to surface existing issues. Report findings briefly but **always continue to Step 2 immediately** — do not stop to ask about audit results. The audit is informational, not a gate. Any issues found will be included in the post-update report (Step 5) alongside the update results.
24
28
 
25
- ### Step 2: Compare current vs payload
29
+ ### Step 1b: Decide where the update lands
26
30
 
27
- For each component directory in `.workspace-update/.claude/` (skills, hooks, agents, rules, recipes), compare files against the corresponding `.claude/{component}/` directory locally:
31
+ Check the workspace repo for a remote (`git remote`). This decides where every later step works:
28
32
 
29
- - **New files:** present in `.workspace-update/.claude/{component}/` but not in `.claude/{component}/`
30
- - **Updated files:** present in both but contents differ
31
- - **Unchanged files:** present in both with identical contents
32
- - **Removed files:** present in `.claude/{component}/` locally but not in `.workspace-update/.claude/{component}/`
33
+ - **No remote** — apply in place. The Step 7 commit lands on the launcher's default branch: the one sanctioned launcher commit, because a repo with no remote has nowhere else for a template update to go. The payload path is `.workspace-update/`.
34
+ - **A remote exists** — the launcher never commits to its default branch. Create a task worktree up front and treat it as the workspace root for Steps 2–6:
35
+ ```bash
36
+ node .claude/scripts/task-worktree.mjs --root . --create --repo . --branch chore/template-update-{version}
37
+ ```
38
+ The payload is untracked, so it does not appear inside the worktree — keep referencing it at the launcher's absolute path (`{launcher}/.workspace-update`). Step 7 commits, pushes, and PRs from the worktree.
39
+
40
+ In the commands below, `{payload}` is `.workspace-update` in the no-remote flow and `{launcher}/.workspace-update` in the worktree flow.
41
+
42
+ ### Step 2: Classify the payload
43
+
44
+ Run the classifier — it compares every verbatim-installed payload file (`.claude/**`, `.mcp.json`, `.claudeignore`) against the workspace by content:
45
+
46
+ ```bash
47
+ node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload}
48
+ ```
49
+
50
+ It runs from the payload precisely so workspaces that don't have it installed yet can use it; once installed, `node .claude/scripts/classify-update.mjs --root .` is equivalent. Output is JSON with three lists:
51
+
52
+ - `new` — no installed counterpart; safe to batch-apply (Step 3) behind one confirmation
53
+ - `identical` — installed file already equals the payload; skip silently
54
+ - `differs` — installed file was locally modified; needs a per-file decision
55
+
56
+ Templates (`*.tmpl`, which install with `{{project-name}}` substitution), `_gitignore` (merged line-by-line), and `.manifest.json` (payload metadata) are not classified — each is handled by its own sub-step in Step 3.
57
+
58
+ Also list **removed files**: files present in the local `.claude/{component}/` with no counterpart in `{payload}/.claude/{component}/`.
33
59
 
34
60
  Report with version info from the manifest:
35
61
  ```
36
- "Template update: v{fromVersion} → v{toVersion}. {N} new files, {M} updated files, {R} removed files, {K} unchanged."
62
+ "Template update: v{fromVersion} → v{templateVersion}. {N} new files, {M} locally modified, {R} removed files, {K} unchanged."
37
63
  ```
38
64
 
39
- If everything is unchanged and there are no new or removed files, report: "Workspace is up to date (template v{toVersion}). No changes needed."
65
+ If `new`, `differs`, and the removed list are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed."
40
66
 
41
67
  ### Step 2b: Historical .gitignore safety check
42
68
 
@@ -57,30 +83,31 @@ Commit the fix **before** applying other template updates. This runs ahead of St
57
83
 
58
84
  ### Step 3: Selective update
59
85
 
60
- For each change, ask before applying:
86
+ Batch the safe case, ask on the rest:
61
87
 
62
- - **New file:** "Add {file}? [Y/n]"
63
- - **Updated file (no local mods):** "Update {file} to latest template? [Y/n]"
64
- - **Updated file (locally modified):** "Template updated {file} but you have local changes. Show diff? [y/N]" — let user decide
65
- - **Removed in template:** "Template removed {file}. Delete locally? [y/N]" — conservative default
88
+ - **New files (`new`):** present the list once — "Apply these {N} new files? [Y/n]" — and install them all on confirmation. No per-file prompting.
89
+ - **Locally modified (`differs`):** ask per file — "Template updated {file} but you have local changes. Show diff? [y/N]" — then apply, keep, or merge per the user's decision.
90
+ - **Removed in template:** "Template removed {file}. Delete locally? [y/N]" — conservative default.
66
91
  - **Hook migration (.sh to .mjs):** Detect old `.sh` hooks in `.claude/hooks/` that have `.mjs` replacements in the payload. Offer: "Hook {name}.sh has a .mjs replacement in the update. Replace and update settings.json commands? [Y/n]" — this is a one-time migration for workspaces upgrading from pre-0.2.0
67
92
 
68
93
  Also handle these non-component files from the payload:
69
94
 
70
95
  - **settings.json:** Merge payload values into existing `.claude/settings.json` — do not overwrite user customizations. Add new keys, update hook commands if hooks were migrated, preserve user-added entries.
71
- - **CLAUDE.md:** If `.workspace-update/CLAUDE.md.tmpl` exists, regenerate `CLAUDE.md` from the template. Preserve any user-added sections not present in the template.
72
- - **.gitignore:** Merge new entries from the payload into the existing `.gitignore` — do not remove user-added lines.
96
+ - **workspace.json keys:** Compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
97
+ - **Rules renamed to `.skip`:** For each active `.claude/rules/{name}.md` whose template counterpart now ships as `{name}.md.skip`, keep the active file — it was deliberately activated — and tell the user that's what happened.
98
+ - **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, regenerate `CLAUDE.md` from the template. Preserve any user-added sections not present in the template.
99
+ - **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines.
73
100
 
74
101
  ### Step 4: Update version
75
102
 
76
- Read `toVersion` from `.workspace-update/.manifest.json` and update `templateVersion` in `workspace.json` to match.
103
+ Read `templateVersion` from `.workspace-update/.manifest.json` and update `templateVersion` in `workspace.json` to match.
77
104
 
78
105
  ### Step 4a: Run idempotent migrators
79
106
 
80
- The payload may include migrator scripts at `.workspace-update/.claude/scripts/migrate-*.mjs` that bring older workspaces forward in shape. They are idempotent — safe to re-run on already-migrated workspaces. Run each one in document order and surface its action in the upgrade summary.
107
+ The payload may include migrator scripts at `{payload}/.claude/scripts/migrate-*.mjs` that bring older workspaces forward in shape. They are idempotent — safe to re-run on already-migrated workspaces. Run each one in document order and surface its action in the upgrade summary.
81
108
 
82
109
  ```bash
83
- node .workspace-update/.claude/scripts/migrate-claude-md-freshness-include.mjs
110
+ node {payload}/.claude/scripts/migrate-claude-md-freshness-include.mjs --root .
84
111
  ```
85
112
 
86
113
  Output is JSON: `{"action":"appended"|"unchanged"|"skipped"}`.
@@ -90,10 +117,12 @@ Output is JSON: `{"action":"appended"|"unchanged"|"skipped"}`.
90
117
  - `skipped` — no `CLAUDE.md` exists at the workspace root (rare; surface to the user).
91
118
 
92
119
  ```bash
93
- node .workspace-update/.claude/scripts/migrate-canonical-priority.mjs --root .
120
+ node {payload}/.claude/scripts/migrate-canonical-priority.mjs --root .
94
121
  ```
95
122
 
96
- Output is JSON: `{"status":"applied"|"noop","files":[...]}`. Back-fills `priority: critical` on every `workspace-context/shared/locked/*.md` that lacks the field, preserving today's full-load behavior until the user explicitly demotes a file. Idempotent — safe to re-run on already-migrated workspaces.
123
+ Output is JSON: `{"status":"applied"|"noop","files":[...]}`. Back-fills `priority: critical` on every `workspace-context/shared/locked/*.md` that lacks the field, preserving today's full-load behavior until the user explicitly demotes a file. Skips `local-only-*` files — they are machine-local, never canonical. Idempotent.
124
+
125
+ Always run migrators with `--root .`. They resolve the workspace root from `--root` (default: the cwd) and never from their own location — a migrator invoked from the payload without `--root` would look for the workspace inside `.workspace-update/`.
97
126
 
98
127
  Add other migrators here as the template ships them.
99
128
 
@@ -104,20 +133,33 @@ Run `/maintenance audit` again to verify the update didn't introduce:
104
133
  - Contradictions between updated rules and existing shared context
105
134
  - Structural mismatches
106
135
 
136
+ Then regenerate the context catalogs — an update that adds or renames files under `.claude/` or `workspace-context/` leaves `index.md`/`canonical.md` stale until they are rebuilt:
137
+
138
+ ```bash
139
+ node .claude/scripts/build-workspace-context.mjs --write --root .
140
+ node .claude/scripts/build-workspace-context.mjs --check --root .
141
+ ```
142
+
143
+ `--check` must exit clean. If it reports drift, re-run `--write` and check again before continuing.
144
+
107
145
  Report: "Post-update verification: {N} issues found" or "Post-update verification clean."
108
146
 
109
147
  ### Step 6: Cleanup
110
148
 
111
- Delete the `.workspace-update/` directory entirely. The payload has been fully processed and is no longer needed.
149
+ Delete the `.workspace-update/` directory at the launcher — in the worktree flow (Step 1b) that happens after the merge and pull in Step 7. The payload has been fully processed and is no longer needed.
112
150
 
113
151
  ### Step 7: Commit
114
152
 
115
- ```bash
116
- git add -A
117
- git commit -m "chore: update workspace from template v{fromVersion} to v{toVersion}"
118
- ```
153
+ Where the commit lands was decided in Step 1b.
154
+
155
+ - **No remote:** commit in place on the launcher's default branch — the one sanctioned launcher commit:
156
+ ```bash
157
+ git add -A
158
+ git commit -m "chore: update workspace from template v{fromVersion} to v{templateVersion}"
159
+ ```
160
+ - **Remote exists:** from the task worktree created in Step 1b, commit the applied update, push the branch, and open a PR through the forge adapter — `node .claude/scripts/task-pr.mjs` when the workspace has it, otherwise the adapter under `.claude/scripts/forges/`. After the PR merges, pull the launcher, then delete the payload (Step 6).
119
161
 
120
- Report: "Workspace updated to v{toVersion}. Restart Claude Code if rules or hooks changed."
162
+ Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules or hooks changed."
121
163
 
122
164
  ### Step 8: Session-model migration nudge
123
165
 
@@ -126,8 +168,9 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
126
168
  ## Notes
127
169
 
128
170
  - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload. This skill processes it.
129
- - Never overwrites without asking
130
- - Preserves local modifications and custom content
171
+ - Never overwrites without asking — `new` files are batched behind one confirmation; `differs` files are asked per file
172
+ - Preserves local modifications, custom content, existing `workspace.json` keys, and deliberately activated rules
173
+ - The launcher's default branch takes a template-update commit only when the workspace has no remote; with a remote, the update lands through a task worktree and a PR
131
174
  - Can be run multiple times safely (idempotent) — if `.workspace-update/` doesn't exist, it reports no payload and exits
132
175
  - Initial setup is handled by `npx @ulysses-ai/create-workspace --init` + `/workspace-init` — this skill is for subsequent updates only
133
176
  - The `.sh` to `.mjs` hook migration is a one-time transition for workspaces created before hooks moved to JavaScript
@@ -12,6 +12,9 @@ work-sessions/
12
12
  # Disposable workspace-scoped scratchpad (session log, hook debug output)
13
13
  workspace-scratchpad/
14
14
 
15
+ # Staged template payload — transient input for /workspace-update, never tracked
16
+ .workspace-update/
17
+
15
18
  # Personal overrides
16
19
  .claude/settings.local.json
17
20
  .claude/.active-session.json