@ulysses-ai/create-workspace 0.23.1-beta.0 → 0.23.3-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.
@@ -134,13 +134,23 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
134
134
  return issue;
135
135
  }
136
136
 
137
+ // `glab issue create` prints the new issue's URL: older GitLab/glab says
138
+ // /-/issues/N, newer says /-/work_items/N (issues became work items
139
+ // under the hood) — the number is the same iid either way. If neither
140
+ // shape appears the issue still WAS created (glab exited 0 and the
141
+ // server accepted it), so the error must say so and carry glab's output
142
+ // — otherwise the caller retries and duplicates the issue.
137
143
  async function createIssue({ title, body = '', labels = [], milestone = null }) {
138
144
  const args = ['issue', 'create', '--repo', repo, '--title', title, '--description', body, '--yes'];
139
145
  if (labels.length > 0) args.push('--label', labels.join(','));
140
146
  if (milestone) args.push('--milestone', milestone);
141
147
  const stdout = glab(args);
142
- const m = stdout.match(/\/-\/issues\/(\d+)/);
143
- if (!m) throw new Error(`Could not parse issue number from: ${stdout.trim()}`);
148
+ const m = stdout.match(/\/-\/(?:issues|work_items)\/(\d+)/);
149
+ if (!m) {
150
+ throw new Error(
151
+ `glab issue create succeeded, but the new issue's number could not be parsed from its output `
152
+ + `— the issue WAS created; do NOT retry (a retry would create a duplicate). glab output: ${stdout.trim()}`);
153
+ }
144
154
  return getIssue(`gl:${m[1]}`);
145
155
  }
146
156
 
@@ -162,7 +172,11 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
162
172
 
163
173
  // The canonical URL for an issue — what a cross-forge reference needs
164
174
  // (a `group/sub#N` reference cannot resolve on a GitHub PR), minted from
165
- // the same host/repo every other URL here is built from.
175
+ // the same host/repo every other URL here is built from. `/-/issues/N`
176
+ // stays the link form even though GitLab now serves issues at work_items
177
+ // URLs — those links still resolve — and every method but createIssue
178
+ // takes `gl:N` ids rather than parsing glab's printed URLs, so this is
179
+ // the only other place the URL shape is chosen, deliberately.
166
180
  function issueUrl(issueId) {
167
181
  const num = parseIssueNumber(issueId);
168
182
  return `https://${host}/${repo}/-/issues/${num}`;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: migrate-sessions
3
- description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script never deletes anything.
3
+ description: Migrate this workspace from the session lifecycle to the task lifecycle — inventory old work sessions, decide each one with the operator, finish or archive them (empty orphan shells only: remove), and switch workspace.json to the task model. Runs only inside the current workspace; the script deletes nothing but verified-empty orphan shells.
4
4
  ---
5
5
 
6
6
  # Migrate Sessions
@@ -40,11 +40,11 @@ For each session, lay out its evidence and ask the operator which way to go. Nev
40
40
  ```bash
41
41
  node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
42
42
  ```
43
- With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` refuses while any tip holds commits no remote backs, and proceeds only with `--allow-unbacked`, the operator's recorded no after seeing the counts.
44
- 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when a remote holds the tips (a pushed backup), or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move), and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
45
- - **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The command re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
43
+ With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` counts what this step pushed (a tip is backed when a remote holds it on a branch or under a `drain/{session}/*` tag), so `--allow-unbacked` is needed only when the operator declined the backup — pass it only on their explicit yes naming the session, after showing them the counts.
44
+ 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted — the result names them (`heldBranches`). The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs on a branch or under a pushed `drain/{session}/*` tag — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when the backup pushed the tips somewhere a remote holds them, or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move; a drain-tag check that did not clear, with the reason — an unreachable remote, a session name the pattern cannot express), report tips the result lists under `backedByTag` as backed by their drain tag, and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
45
+ - **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The scripted removal re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
46
46
  ```bash
47
- node -e "const fs=require('fs');const p=process.argv[1];const empty=d=>fs.readdirSync(d,{withFileTypes:true}).every(e=>e.isDirectory()&&empty(d+'/'+e.name));if(!empty(p)){console.error(p+' is not empty — left alone');process.exit(1)}fs.rmSync(p,{recursive:true});console.log('removed empty shell '+p)" work-sessions/{name}
47
+ node .claude/scripts/migrate-sessions.mjs --remove-shell --session {name}
48
48
  ```
49
49
  - **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle. A kept session cannot be converted to a task in place — no converter exists, and the lifecycles keep their state differently (a session folder with a tracker vs. a branch with a chat-record entry). The supported equivalent: finish the session (merge it) and start the remaining work as a task, or keep it under the session lifecycle until it is done. Do not improvise a conversion by hand.
50
50
 
@@ -58,26 +58,33 @@ The switch procedure:
58
58
  ```bash
59
59
  node .claude/scripts/task-worktree.mjs --root . --create --repo . --branch chore/enable-task-model
60
60
  ```
61
- 2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`):
61
+ 2. Run the switch against that worktree (the one mode that accepts a linked-worktree root — it only edits `workspace.json`), passing the launcher so the remaining-session count comes back with the result:
62
62
  ```bash
63
- node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model
63
+ node .claude/scripts/migrate-sessions.mjs --enable-task-model --root .claude/worktrees/chore-enable-task-model --launcher .
64
64
  ```
65
- 3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, then create, ask before merging, and pull the launcher after the merge:
65
+ 3. Land the change the way any task lands. A forge-hosted remote (GitHub, GitLab) means a PR/MR through `task-pr.mjs` — the launcher's default branch is protected on a real forge, so landing directly on it is not an option anyway. Commit in the worktree, write a short PR body (what changed, how it was verified) to a scratch file under `workspace-scratchpad/`, and create the PR:
66
66
  ```bash
67
67
  node .claude/scripts/task-pr.mjs --create --root . --branch chore/enable-task-model \
68
68
  --repo . --body-file .=workspace-scratchpad/switch-pr.md --out workspace-scratchpad/switch-prs.json
69
- node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
70
69
  ```
71
- Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree, fast-forward the launcher's default branch, remove the worktree:
70
+ Only a workspace whose repo has no remote at all (`git -C . remote -v` empty) lands locally — commit in the worktree and leave the landing to step 4:
72
71
  ```bash
73
72
  git -C .claude/worktrees/chore-enable-task-model add workspace.json
74
73
  git -C .claude/worktrees/chore-enable-task-model commit -m "chore: switch to the task lifecycle"
75
- git -C . merge --ff-only chore/enable-task-model
76
- node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
77
74
  ```
75
+ 4. **Ask the operator before merging** — a step of its own, not a clause inside step 3, because a one-line diff is still the launcher's default branch and "it's only a one-line change" is not permission. Ask exactly: "The task-model switch is ready to merge into the launcher's default branch. Merge it now?" Only an explicit yes merges; on yes:
76
+ - forge remote — merge the PR, then pull the launcher:
77
+ ```bash
78
+ node .claude/scripts/task-pr.mjs --merge --root . --prs workspace-scratchpad/switch-prs.json
79
+ ```
80
+ - no remote — fast-forward the launcher's default branch, then remove the worktree:
81
+ ```bash
82
+ git -C . merge --ff-only chore/enable-task-model
83
+ node .claude/scripts/task-worktree.mjs --root . --remove --repo . --branch chore/enable-task-model --delete-branch
84
+ ```
78
85
  Either way the launcher root never commits to its default branch directly. A workspace with neither a remote nor a tracker can use the task model's local mode (gh:173); until that ships, recommend such workspaces stay on sessions.
79
86
 
80
- The switch output reports `remainingSessions: null` when run from the worktree — the real remaining-sessions list comes from a separate `--inventory` at the launcher root. Remaining sessions are fine either way: they keep resuming and completing under the session lifecycle after the switch.
87
+ With `--launcher .` the switch output reports `remainingSessions` from the launcher even though `--root` is the switch worktree (which has no `work-sessions/` to read; without the flag the result is `remainingSessions: null` with a note pointing at a separate `--inventory` at the launcher root). Remaining sessions are fine either way: they keep resuming and completing under the session lifecycle after the switch.
81
88
 
82
89
  ## 4. Verify
83
90
 
@@ -54,7 +54,7 @@ It runs from the payload precisely so workspaces that don't have it installed ye
54
54
  - `new` — no installed counterpart and no baseline entry; safe to batch-apply (Step 3) behind one confirmation
55
55
  - `identical` — installed file already equals the payload; skip silently
56
56
  - `updated` — installed file still holds the baseline content while the payload ships something new: a pure template change the user never touched. Batched with `new` behind one confirmation
57
- - `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision
57
+ - `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision — Step 3 merges these three-way when a merge base is staged
58
58
  - `config` — `.mcp.json` and `.claude/settings.json`: JSON the workspace owns jointly with the template — never compared by content, never batch-copied. Each entry carries a key-level diff (`added` keys the template ships, `workspaceOnly` keys only the workspace has, `changed` keys with different values; nested paths like `mcpServers/{server}`), element diffs for array-valued keys (`arrays`: `{ path, added, workspaceOnly }` element lists for `hooks/{event}`, `permissions/allow`/`deny`), or a `notInstalled` / `unparseable` flag. Merged key by key in Step 3.
59
59
  - `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied
60
60
  - `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list
@@ -98,7 +98,15 @@ Commit the fix **before** applying other template updates — on the task branch
98
98
  Batch the safe cases, ask on the rest:
99
99
 
100
100
  - **New and template-updated files (`new` + `updated`):** present both lists once — "Apply these {N} new and {U} template-updated files? [Y/n]" — and install them all on confirmation. No per-file prompting: `updated` means the file still holds exactly what the template last shipped here, so applying the new version loses nothing.
101
- - **Locally modified (`differs`):** ask per file — "Your version of {file} differs from the template's. Show diff? [y/N]" — then apply, keep, or merge per the user's decision.
101
+ - **Locally modified (`differs`):** three-way merge with per-file operator approval — never a silent resolution. When the payload carries a merge base (`.template-base/`, staged by `--upgrade` from the installed version's npm tarball whenever it could be fetched), run the merge helper first:
102
+ ```bash
103
+ node {payload}/.claude/scripts/template-merge.mjs --root . --payload {payload} --baseline {baseline}
104
+ ```
105
+ (`{baseline}` resolves as in Step 2; `--files a,b` re-runs a subset, `--out <dir>` redirects the output.) It merges each `differs` file with `git merge-file` — the workspace copy as "local", the staged base as the common ancestor, the payload's copy as "template" — and prints `{ merged: [{path, conflicts: 0, out}], conflicted: [{path, conflicts: N, out}], noBase: [path], errors: [...] }`, writing the merged text under `{payload}/.merged/` mirroring each path. It never writes a workspace file; applying is this skill's job, on approval:
106
+ - **Clean merges (`merged`):** show the diff workspace→merged for each file (`git diff --no-index` the workspace file against its `out`), then ONE batched confirmation — "Apply these {N} clean merges? [Y/n]" — listing the files. On yes, copy each `out` file to the same path in the workspace (the update worktree in the remote flow).
107
+ - **Conflicts (`conflicted`):** one file at a time. Show the conflict hunks (`<<<<<<< local` … `>>>>>>> template`), propose a resolution, and write the resolved file only after the operator's yes for THAT file. Never resolve a conflict silently, and never delegate resolution to a subagent or any unattended step without the operator approving each file.
108
+ - **`noBase`** (no staged base for the path, or its hash does not match the baseline — the base is not this workspace's ancestor): fall back to the two-way ask — "Your version of {file} differs from the template's. Show diff? [y/N]" — then apply, keep, or merge by hand per the user's decision. Expect a previously declined update here: its baseline entry keeps the older version's hash (Step 4's rule), so the newly staged base no longer validates and the file takes this per-file decision.
109
+ When the payload carries no `.template-base/` at all (the CLI could not fetch the installed version — offline or unpublished; the helper reports `templateBase: null`), every `differs` file takes the `noBase` path — say so once in the report.
102
110
  - **Config files (`config`):** `.mcp.json` and `.claude/settings.json` are never copied wholesale — a batch copy wipes the workspace's own MCP servers and settings. Merge each entry key by key (values from `{payload}/{path}` and the workspace's copy): add every `added` key, keep every `workspaceOnly` key untouched, and for each `changed` key ask — "Template changed `{key}` in `{path}`. Take the template's, keep yours, or inspect?" Array-valued keys merge as a union, no ask: keep the workspace's elements in place and append each `arrays` entry's `added` elements (`workspaceOnly` elements are already in place, listed for visibility). `notInstalled` — ask once: "Install {path} from the template? [Y/n]" (never install a config silently); `unparseable` means broken JSON on one side — show the file and ask, never merge blind.
103
111
  - **Local-only edits (`localOnly`):** nothing to decide — these are your local edits to files the template hasn't changed since the last update. List them in the summary (so the edits are visible) and move on; do not ask about them.
104
112
  - **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list. Restoring installs the payload's version of each.
@@ -187,7 +195,7 @@ Report: "Post-update verification: {N} issues found" or "Post-update verificatio
187
195
 
188
196
  ### Step 6: Cleanup
189
197
 
190
- Delete the `.workspace-update/` directory at the launcher — in the worktree flow (Step 1) that happens after the merge and pull in Step 7. The payload has been fully processed and is no longer needed.
198
+ Delete the `.workspace-update/` directory at the launcher — in the worktree flow (Step 1) that happens after the merge and pull in Step 7. The payload has been fully processed and is no longer needed. That one deletion also removes the `.template-base/` and `.merged/` staging this update created — both live inside `.workspace-update/`, nowhere else.
191
199
 
192
200
  ### Step 7: Commit
193
201
 
@@ -225,8 +233,8 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
225
233
 
226
234
  ## Notes
227
235
 
228
- - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), and stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull, rebuilds the per-user catalogs the pull may delete, and removes the update worktree with its branch
229
- - Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are asked per file; `config` files (`.mcp.json`, `.claude/settings.json`) are merged key by key (array-valued keys union-merged) and never copied wholesale; `localOnly` files are never asked about (local edits to files the template didn't touch)
236
+ - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none, and stages the installed version's template files as `.template-base/` (the merge base `differs` files are merged against) whenever that tarball could be fetched — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull, rebuilds the per-user catalogs the pull may delete, and removes the update worktree with its branch
237
+ - Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are three-way merged against the staged base when there is one (clean merges batch behind one confirmation, conflicts are resolved one file at a time with the operator's yes, files without a base fall back to the per-file ask); `config` files (`.mcp.json`, `.claude/settings.json`) are merged key by key (array-valued keys union-merged) and never copied wholesale; `localOnly` files are never asked about (local edits to files the template didn't touch)
230
238
  - Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys, and deliberately activated rules
231
239
  - The template baseline (`.claude/.template-baseline.json`) is what separates `updated`, `differs`, and `localOnly`: entries hold the payload hash of the last-shipped content (unapplied updates keep the older entry), so a deliberately kept local edit stays visible across updates while an untouched file never prompts
232
240
  - The launcher's default branch takes a template-update commit only when the workspace repo has no remote; with ANY remote, the update lands through a task worktree, a branch, and a PR/MR — the default branch is never pushed directly