@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.
- package/lib/upgrade.mjs +127 -41
- package/lib/upgrade.test.mjs +138 -14
- package/package.json +1 -1
- package/template/_claude/rules/workspace-structure.md +2 -1
- package/template/_claude/scripts/forges/gitlab.mjs +6 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/template-merge.mjs +255 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +17 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/workspace-update/SKILL.md +13 -5
|
@@ -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)
|
|
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
|
|
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
|
|
|
@@ -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`):**
|
|
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),
|
|
229
|
-
- Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are
|
|
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
|