@ulysses-ai/create-workspace 0.23.0-beta.0 → 0.23.2-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/classify-update.mjs +155 -7
- package/template/_claude/scripts/forges/gitlab.mjs +6 -1
- package/template/_claude/scripts/maintenance-audit.mjs +47 -8
- package/template/_claude/scripts/migrate-sessions.mjs +98 -16
- package/template/_claude/scripts/template-merge.mjs +255 -0
- package/template/_claude/skills/migrate-sessions/SKILL.md +10 -6
- package/template/_claude/skills/workspace-update/SKILL.md +40 -19
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Three-way merge for the `differs` files of an upgrade payload (gh:193).
|
|
3
|
+
//
|
|
4
|
+
// classify-update.mjs files a payload file as `differs` when the workspace
|
|
5
|
+
// copy and the template's new copy both left the baseline — a local edit
|
|
6
|
+
// meeting a template change. Picking one side whole loses the other, so this
|
|
7
|
+
// script merges instead, using the template files of the version being
|
|
8
|
+
// upgraded FROM as the common ancestor. `--upgrade` stages those at
|
|
9
|
+
// .workspace-update/.template-base/ under live names (lib/upgrade.mjs owns
|
|
10
|
+
// the staging) whenever it could fetch the installed version's npm tarball;
|
|
11
|
+
// offline or unpublished, the base is absent and every file reports noBase.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node template-merge.mjs --root <dir> --payload <dir> [--baseline <file>]
|
|
15
|
+
// [--out <dir>] [--files a,b]
|
|
16
|
+
//
|
|
17
|
+
// --root the workspace root (the update worktree in the remote flow);
|
|
18
|
+
// defaults to the current working directory, never derived from
|
|
19
|
+
// this script's location
|
|
20
|
+
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
21
|
+
// --baseline the baseline base content is validated against; resolves
|
|
22
|
+
// exactly like classify-update's (the root's baseline first,
|
|
23
|
+
// then the payload's reconstructed one) when omitted
|
|
24
|
+
// --out where merged text lands, mirroring each path; defaults to
|
|
25
|
+
// <payload>/.merged — NEVER the workspace file itself
|
|
26
|
+
// --files a comma-separated subset of the differs list to process
|
|
27
|
+
//
|
|
28
|
+
// For each differs file, the base at <payload>/.template-base/<path> must
|
|
29
|
+
// exist and, when the baseline records the path, hash (template-baseline's
|
|
30
|
+
// hashBytes) to the baseline's entry — otherwise the file reports noBase: a
|
|
31
|
+
// base the baseline disproves is not this workspace's ancestor, and merging
|
|
32
|
+
// against it would fabricate conflicts or silently drop local edits. With a
|
|
33
|
+
// base, `git merge-file` merges local vs base vs template; its exit status
|
|
34
|
+
// is the conflict count (0 = clean merge, >0 = conflicts, anything else =
|
|
35
|
+
// error). The merged bytes pass through untouched — a file that isn't
|
|
36
|
+
// valid UTF-8 survives a clean merge byte-exact — and a CRLF local copy is
|
|
37
|
+
// folded to LF for the merge and restored to CRLF after, so a Windows
|
|
38
|
+
// checkout neither conflicts wholesale nor loses its line-ending style.
|
|
39
|
+
//
|
|
40
|
+
// Prints JSON:
|
|
41
|
+
// { "merged": [{ path, conflicts: 0, out }],
|
|
42
|
+
// "conflicted": [{ path, conflicts: N, out }],
|
|
43
|
+
// "noBase": [path],
|
|
44
|
+
// "errors": [{ path, message }],
|
|
45
|
+
// "out": "<resolved output dir>",
|
|
46
|
+
// "templateBase": "<resolved base dir, or null when none was staged>" }
|
|
47
|
+
//
|
|
48
|
+
// Merged text — conflict markers `<<<<<<< local` / `>>>>>>> template`
|
|
49
|
+
// included — lands ONLY in the output dir. Applying an approved result is
|
|
50
|
+
// /workspace-update's job: copying the file into the workspace one
|
|
51
|
+
// operator-approved file at a time, never silently.
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
existsSync,
|
|
55
|
+
mkdirSync,
|
|
56
|
+
mkdtempSync,
|
|
57
|
+
readFileSync,
|
|
58
|
+
realpathSync,
|
|
59
|
+
rmSync,
|
|
60
|
+
writeFileSync,
|
|
61
|
+
} from 'node:fs';
|
|
62
|
+
import { tmpdir } from 'node:os';
|
|
63
|
+
import { dirname, join, resolve } from 'node:path';
|
|
64
|
+
import { fileURLToPath } from 'node:url';
|
|
65
|
+
import { spawnSync } from 'node:child_process';
|
|
66
|
+
import { classifyUpdate, resolveBaseline } from './classify-update.mjs';
|
|
67
|
+
import { hashBytes } from './template-baseline.mjs';
|
|
68
|
+
|
|
69
|
+
export const TEMPLATE_BASE_DIR = '.template-base';
|
|
70
|
+
export const MERGED_DIR = '.merged';
|
|
71
|
+
|
|
72
|
+
function isMainModule(metaUrl) {
|
|
73
|
+
if (!process.argv[1]) return false;
|
|
74
|
+
try {
|
|
75
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const args = { root: process.cwd(), payload: null, baseline: null, out: null, files: null };
|
|
81
|
+
for (let i = 2; i < argv.length; i++) {
|
|
82
|
+
const a = argv[i];
|
|
83
|
+
if (a === '--root') args.root = argv[++i];
|
|
84
|
+
else if (a === '--payload') args.payload = argv[++i];
|
|
85
|
+
else if (a === '--baseline') args.baseline = argv[++i];
|
|
86
|
+
else if (a === '--out') args.out = argv[++i];
|
|
87
|
+
else if (a === '--files') args.files = argv[++i];
|
|
88
|
+
else throw new Error(`Unknown arg: ${a}`);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// git merge-file's exit status is the conflict count, truncated to 127; a
|
|
94
|
+
// status outside 0–127 (or a spawn failure, or binary input git refuses to
|
|
95
|
+
// merge) is an error, not a merge.
|
|
96
|
+
const MAX_CONFLICT_STATUS = 127;
|
|
97
|
+
const MERGE_BUFFER = 16 * 1024 * 1024;
|
|
98
|
+
|
|
99
|
+
function gitMergeFile(local, base, template) {
|
|
100
|
+
// -p prints the merged text to stdout instead of overwriting <local> —
|
|
101
|
+
// the workspace file is never touched. The -L labels name the three sides
|
|
102
|
+
// in the order the files follow, so conflict hunks read
|
|
103
|
+
// `<<<<<<< local` … `>>>>>>> template`. Buffers end to end (no encoding):
|
|
104
|
+
// decoding to a string would turn every non-UTF-8 byte into U+FFFD and
|
|
105
|
+
// corrupt a "clean" merge's output.
|
|
106
|
+
const r = spawnSync('git', [
|
|
107
|
+
'merge-file', '-p',
|
|
108
|
+
'-L', 'local', '-L', 'base', '-L', 'template',
|
|
109
|
+
local, base, template,
|
|
110
|
+
], { maxBuffer: MERGE_BUFFER });
|
|
111
|
+
if (r.error || r.status === null || r.status < 0 || r.status > MAX_CONFLICT_STATUS) {
|
|
112
|
+
const detail = (r.stderr && r.stderr.toString('utf8').trim())
|
|
113
|
+
|| (r.error && r.error.message)
|
|
114
|
+
|| `git merge-file exited with status ${r.status}`;
|
|
115
|
+
return { error: detail };
|
|
116
|
+
}
|
|
117
|
+
return { conflicts: r.status, bytes: r.stdout ?? Buffer.alloc(0) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- line endings ----------
|
|
121
|
+
//
|
|
122
|
+
// The baseline's hashes fold CRLF to LF, but git merge-file compares bytes:
|
|
123
|
+
// a CRLF working copy against LF base and template inputs conflicts on
|
|
124
|
+
// every line of the file. When the local copy is CRLF text, fold all three
|
|
125
|
+
// inputs to LF in temp files for the merge, then restore the local style on
|
|
126
|
+
// the result. Binary (NUL-bearing) and LF-local files take the byte-exact
|
|
127
|
+
// path with no temp files.
|
|
128
|
+
|
|
129
|
+
// latin1 round-trips bytes 1:1 — the same trick hashBytes uses — so the
|
|
130
|
+
// fold never mangles bytes that aren't valid UTF-8.
|
|
131
|
+
function foldCrLf(bytes) {
|
|
132
|
+
if (bytes.includes(0)) return bytes;
|
|
133
|
+
return Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isCrlfText(bytes) {
|
|
137
|
+
return !bytes.includes(0) && bytes.includes('\r\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeTempInput(tmpDir, name, bytes) {
|
|
141
|
+
const p = join(tmpDir, name);
|
|
142
|
+
writeFileSync(p, bytes);
|
|
143
|
+
return p;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge one file trio, returning { conflicts, bytes } or { error }. When
|
|
148
|
+
* the local copy is CRLF text the merge runs on LF-folded copies under a
|
|
149
|
+
* temp dir (removed afterwards) and the result comes back in the local
|
|
150
|
+
* CRLF style — every `\n` in the folded output stood for a line the local
|
|
151
|
+
* file ends with CRLF. The payload's base and template files, and the
|
|
152
|
+
* workspace file, are only ever read here.
|
|
153
|
+
*/
|
|
154
|
+
function mergeTrio(local, base, template) {
|
|
155
|
+
const localBytes = readFileSync(local);
|
|
156
|
+
if (!isCrlfText(localBytes)) return gitMergeFile(local, base, template);
|
|
157
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'template-merge-'));
|
|
158
|
+
try {
|
|
159
|
+
const merged = gitMergeFile(
|
|
160
|
+
writeTempInput(tmpDir, 'local', foldCrLf(localBytes)),
|
|
161
|
+
writeTempInput(tmpDir, 'base', foldCrLf(readFileSync(base))),
|
|
162
|
+
writeTempInput(tmpDir, 'template', foldCrLf(readFileSync(template))),
|
|
163
|
+
);
|
|
164
|
+
if (merged.error) return merged;
|
|
165
|
+
const crlf = Buffer.from(merged.bytes.toString('latin1').replace(/\n/g, '\r\n'), 'latin1');
|
|
166
|
+
return { conflicts: merged.conflicts, bytes: crlf };
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Merge the payload's `differs` files three-way. Classification comes from
|
|
174
|
+
* classifyUpdate (imported, not re-derived) so the merge set is exactly what
|
|
175
|
+
* Step 2 of /workspace-update showed the operator. Merged text — clean or
|
|
176
|
+
* conflicted — is written under `out` (default <payload>/.merged/) mirroring
|
|
177
|
+
* each path; the workspace files themselves are never written here.
|
|
178
|
+
*/
|
|
179
|
+
export function mergeTemplateFiles({ root, payload = null, baseline = null, out = null, files = null }) {
|
|
180
|
+
const absRoot = resolve(root);
|
|
181
|
+
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
182
|
+
if (!existsSync(absPayload)) {
|
|
183
|
+
throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const classification = classifyUpdate({ root: absRoot, payload: absPayload, baseline });
|
|
187
|
+
const differsSet = new Set(classification.differs);
|
|
188
|
+
const baseDir = join(absPayload, TEMPLATE_BASE_DIR);
|
|
189
|
+
|
|
190
|
+
const result = {
|
|
191
|
+
merged: [],
|
|
192
|
+
conflicted: [],
|
|
193
|
+
noBase: [],
|
|
194
|
+
errors: [],
|
|
195
|
+
out: resolve(out ?? join(absPayload, MERGED_DIR)),
|
|
196
|
+
templateBase: existsSync(baseDir) ? baseDir : null,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let targets = classification.differs;
|
|
200
|
+
if (files !== null) {
|
|
201
|
+
const wanted = files.split(',').map((s) => s.trim()).filter(Boolean);
|
|
202
|
+
targets = [];
|
|
203
|
+
for (const rel of wanted) {
|
|
204
|
+
if (differsSet.has(rel)) targets.push(rel);
|
|
205
|
+
else result.errors.push({ path: rel, message: 'not classified as differs — only differs files can be merged' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { baseline: resolvedBaseline } = resolveBaseline({ root: absRoot, payload: absPayload, baseline });
|
|
210
|
+
for (const rel of targets) {
|
|
211
|
+
const local = join(absRoot, rel);
|
|
212
|
+
const template = join(absPayload, rel);
|
|
213
|
+
const base = join(baseDir, rel);
|
|
214
|
+
if (!existsSync(local) || !existsSync(template)) {
|
|
215
|
+
result.errors.push({ path: rel, message: 'local or template copy missing' });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(base)) {
|
|
219
|
+
result.noBase.push(rel);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const baselineHash = resolvedBaseline ? resolvedBaseline.files[rel] : undefined;
|
|
223
|
+
if (baselineHash !== undefined && hashBytes(readFileSync(base)) !== baselineHash) {
|
|
224
|
+
result.noBase.push(rel);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const merge = mergeTrio(local, base, template);
|
|
228
|
+
if (merge.error) {
|
|
229
|
+
result.errors.push({ path: rel, message: merge.error });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const outFile = join(result.out, rel);
|
|
233
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
234
|
+
writeFileSync(outFile, merge.bytes);
|
|
235
|
+
const entry = { path: rel, conflicts: merge.conflicts, out: outFile };
|
|
236
|
+
if (merge.conflicts === 0) result.merged.push(entry);
|
|
237
|
+
else result.conflicted.push(entry);
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function main() {
|
|
243
|
+
const args = parseArgs(process.argv);
|
|
244
|
+
const result = mergeTemplateFiles(args);
|
|
245
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isMainModule(import.meta.url)) {
|
|
249
|
+
try {
|
|
250
|
+
main();
|
|
251
|
+
} catch (err) {
|
|
252
|
+
process.stderr.write(`template-merge: ${err.message}\n`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -22,29 +22,33 @@ Read-only. Present the stderr table plus each session's proposal with its reason
|
|
|
22
22
|
Three more kinds of evidence change what you propose:
|
|
23
23
|
|
|
24
24
|
- **Fetch age** — every worktree line ends `fetch:…` with the age of its repo's last fetch, and anything over a day draws a `stale-fetch` warning: the commits-ahead and content counts ride on tracking refs frozen at that fetch. Tell the operator to fetch first, or re-run with `--inventory --fetch`, which fetches each touched source clone before inspecting it. A fetch is read-only with respect to the workspace's own state — it moves no local branch and touches no worktree, only refs/remotes/* and the object store — and a repo with no origin or an unreachable one is recorded as skipped or failed, never fatal.
|
|
25
|
-
- **Open chats** — `chat open?` on a session (with a `chat-open` warning) means its tracker records a chat session with no `ended:` — a chat may still be working in it
|
|
25
|
+
- **Open chats** — `chat open?` on a session (with a `chat-open` warning) means its tracker records a chat session with no `ended:` AND the session shows activity within the active window (14 days by default) — a chat may still be working in it; confirm with the operator before proposing Archive. An open-chat entry on a session with nothing recent is a stale record, not a live chat — session-end misses often enough (a crashed chat, a skipped hook) — so it shows as `chat: no end recorded (idle {N}d)` with an informational `chat-open-idle` entry instead. Only the recent kind changes what Archive means.
|
|
26
26
|
- **External worktrees** — a trailing section lists worktrees the workspace's repos register outside the workspace root (a scratch checkout in tmp, a directory elsewhere). They belong to no session: nothing in this migration prunes, moves, or removes them, and you never should either — including by hand, because `git worktree prune` has no path filter and would drop their records.
|
|
27
27
|
|
|
28
28
|
## 2. Decide per session, with the operator — one at a time
|
|
29
29
|
|
|
30
30
|
For each session, lay out its evidence and ask the operator which way to go. Never infer the decision from the proposal. The options:
|
|
31
31
|
|
|
32
|
-
- **Finish** (typical for MERGEABLE, and the default offer for READY_TO_COMPLETE — a session `/complete-work` stopped partway through: tracker already stripped, worktrees still live, branch often already pushed) —
|
|
32
|
+
- **Finish** (typical for MERGEABLE, and the default offer for READY_TO_COMPLETE — a session `/complete-work` stopped partway through: tracker already stripped, worktrees still live, branch often already pushed) — finish it from **this same chat**, the launcher chat. A new chat is not needed and would not work: `/complete-work` finds a session from the launcher only when this chat's id is registered in that session's `chatSessions`, and `/start-work`'s resume flow is what registers it. In this chat:
|
|
33
|
+
1. Run `/start-work` and pick the session to resume it. (A READY_TO_COMPLETE session has no tracker, and `/start-work`'s walk lists sessions by their tracker, so it will not appear — name it and re-create a minimal tracker from the inventory's `branch`/`repos` first, then resume.)
|
|
34
|
+
2. Run `/complete-work` — since v0.23 it detects sessions this chat is registered on straight from the launcher, asks which to finish, and proceeds with its own merge confirmation.
|
|
35
|
+
|
|
36
|
+
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.
|
|
33
37
|
- **Archive** (typical for ABANDONED, an ORPHAN_SHELL that still holds files, or a MERGEABLE the operator gives up on) — take it out of the active lifecycle without destroying anything. When the inventory shows `chat open?`, confirm with the operator that the chat is really done before offering Archive — a live chat's uncommitted work would ride into the archive unseen. Three steps, in this order, each its own decision:
|
|
34
38
|
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.
|
|
35
39
|
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:
|
|
36
40
|
```bash
|
|
37
41
|
node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
|
|
38
42
|
```
|
|
39
|
-
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.
|
|
40
|
-
3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}
|
|
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.
|
|
41
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:
|
|
42
46
|
```bash
|
|
43
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}
|
|
44
48
|
```
|
|
45
|
-
- **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle.
|
|
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.
|
|
46
50
|
|
|
47
|
-
Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one.
|
|
51
|
+
Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one. That is why the backup decision is a gate rather than a suggestion: the archive itself refuses until the backup step ran or the decline is recorded, and its result names what rode along.
|
|
48
52
|
|
|
49
53
|
## 3. Switch — never write the launcher's tracked `workspace.json` directly
|
|
50
54
|
|
|
@@ -34,9 +34,9 @@ ANY remote — even one you cannot push to — routes the update through a workt
|
|
|
34
34
|
|
|
35
35
|
- **A remote exists (the normal case)** — create a task worktree up front and treat it as the workspace root for Steps 2–6:
|
|
36
36
|
```bash
|
|
37
|
-
node
|
|
37
|
+
node {scripts}/task-worktree.mjs --root . --create --repo . --branch chore/template-update-{version}
|
|
38
38
|
```
|
|
39
|
-
The payload is untracked, so it does not appear inside the worktree — keep referencing it at the launcher's absolute path (`{launcher}/.workspace-update`). Everything the update needs travels with the payload, including any `.template-baseline.reconstructed.json` the CLI staged for a pre-baseline workspace. Step 7 commits, pushes, and opens the PR/MR from the worktree.
|
|
39
|
+
`{scripts}` is `.claude/scripts` when the workspace has the script, `{payload}/.claude/scripts` when it doesn't — pre-0.18 workspaces predate the task scripts entirely, and the payload always carries them (the same rule Step 2 already uses for the classifier). The payload is untracked, so it does not appear inside the worktree — keep referencing it at the launcher's absolute path (`{launcher}/.workspace-update`). Everything the update needs travels with the payload, including any `.template-baseline.reconstructed.json` the CLI staged for a pre-baseline workspace. Step 7 commits, pushes, and opens the PR/MR from the worktree.
|
|
40
40
|
- **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/`.
|
|
41
41
|
|
|
42
42
|
In the commands below, `{payload}` is `.workspace-update` in the no-remote flow and `{launcher}/.workspace-update` in the worktree flow.
|
|
@@ -54,13 +54,14 @@ 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
|
|
61
61
|
- `activated` — the payload ships `rules/{name}.md.skip` while the workspace keeps `{name}.md` active: the rule was deliberately activated. Nothing to install — the active rule stays.
|
|
62
|
-
- `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically,
|
|
62
|
+
- `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically. An entry is a plain path, `{ file, referencedBy }` — a hook a workspace-only `settings.json` entry still registers (the paths say where) — or `{ file, userOwned: true }` — no baseline record, so the template never shipped it and it is the workspace's own.
|
|
63
63
|
- `staleTests` — `*.test.mjs` files under `.claude/` the payload doesn't carry. The package never ships tests, so these came from a dev checkout and no update refreshes them (Step 3 offers removal).
|
|
64
|
+
- `implicitDefaults` — `workspace.json` keys whose absence carried a default in the version being upgraded from. Today: `canonicalBudgetBytes`, an implicit 40960-byte budget between v0.15.0-beta.1 and v0.19.0-beta.0 (before v0.15 there was no budget at all; since v0.19 absent means off). An upgrade from inside that window into a workspace.json that never set the key reports `{ key, value, reason }` — Step 3's workspace.json step writes the value explicitly so trimming doesn't silently stop.
|
|
64
65
|
|
|
65
66
|
Content is compared with line endings normalized (CRLF ≡ LF; binary files byte-exact), so a Windows autocrlf checkout does not read as locally modified.
|
|
66
67
|
|
|
@@ -73,7 +74,7 @@ Report with version info from the manifest:
|
|
|
73
74
|
"Template update: v{fromVersion} → v{templateVersion}. {N} new files, {U} template-updated, {M} locally modified, {C} config files to merge, {L} local-only edits, {D} deleted locally, {A} activated rules, {R} removed files, {K} unchanged."
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
If `new`, `updated`, `differs`, `config` (an entry with empty `added`/`workspaceOnly`/`changed` lists and no array elements to merge counts as empty), `deletedLocally`, `activated`, and `
|
|
77
|
+
If `new`, `updated`, `differs`, `config` (an entry with empty `added`/`workspaceOnly`/`changed` lists and no array elements to merge counts as empty), `deletedLocally`, `activated`, `removed`, and `implicitDefaults` are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed." (`localOnly` files are informational and `staleTests` may still be worth offering.)
|
|
77
78
|
|
|
78
79
|
### Step 2b: Historical .gitignore safety check
|
|
79
80
|
|
|
@@ -97,23 +98,31 @@ Commit the fix **before** applying other template updates — on the task branch
|
|
|
97
98
|
Batch the safe cases, ask on the rest:
|
|
98
99
|
|
|
99
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.
|
|
100
|
-
- **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.
|
|
101
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.
|
|
102
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.
|
|
103
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.
|
|
104
|
-
- **Removed in template (`removed`):** "Template removed {file}. Delete locally? [y/N]"
|
|
113
|
+
- **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in `workspace.json` → `workspace.localFiles` instead, showing the exact entry (`"localFiles": ["skills/my-skill/**"]`, or `["rules/my-rule.md"]` for a single file) so future updates skip it.
|
|
105
114
|
- **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
|
|
106
115
|
- **Stale tests (`staleTests`):** "These {N} test files under .claude/ came from a dev checkout — the package never ships them, so updates can't refresh them (tests live in the template repo). Remove them? [Y/n]" — one confirmation for the whole list.
|
|
107
116
|
- **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
|
|
108
117
|
|
|
109
118
|
Also handle these non-component files from the payload:
|
|
110
119
|
|
|
111
|
-
- **workspace.json keys:**
|
|
120
|
+
- **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then 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.
|
|
112
121
|
- **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, merge — never regenerate from scratch:
|
|
113
122
|
```bash
|
|
114
123
|
node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --merge-claude-md
|
|
115
124
|
```
|
|
116
|
-
The command prints the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result. (The two JSON configs are the `config` list's, not this block's.)
|
|
125
|
+
The command prints JSON `{ claudeMd, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
|
|
117
126
|
- **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines. An ignore pattern does not untrack already-committed files: if the workspace still tracks the per-machine catalogs the template now ignores (`git ls-files -- 'workspace-context/team-member/*/index.md'`), untrack them (`git rm -r --cached 'workspace-context/team-member/*/index.md'`), or every machine's regenerations keep dirtying pulls.
|
|
118
127
|
|
|
119
128
|
### Step 4: Update version and write the baseline
|
|
@@ -176,7 +185,7 @@ node {payload}/.claude/scripts/maintenance-audit.mjs --root . --changed workspac
|
|
|
176
185
|
|
|
177
186
|
Run it from the payload for the same reason as the classifier in Step 2: the workspace's own copy may predate this update. It reuses the sections of `/maintenance` audit that a script can decide (cross-references, frontmatter, structure, git state, catalog integrity, budgets, freshness) and marks findings on changed files `(from this update)`.
|
|
178
187
|
|
|
179
|
-
Present its report as the verification result. Git-state warnings are expected here — the update's commit hasn't landed yet (Step 7). Delete the temp changed list afterwards.
|
|
188
|
+
Present its report as the verification result. Git-state warnings are expected here — the update's commit hasn't landed yet (Step 7) — and the launcher's replaced `workspace-update` skill copy is reported as info while it matches the payload's. Missing `@local-only-*` imports are info too (machine-local files never appear inside the worktree). Delete the temp changed list afterwards.
|
|
180
189
|
|
|
181
190
|
- Findings labeled `(from this update)` — caused by this update; fix before committing (usually a new skill missing from CLAUDE.md's list, or a stale catalog).
|
|
182
191
|
- Other findings — pre-existing; mention briefly.
|
|
@@ -186,7 +195,7 @@ Report: "Post-update verification: {N} issues found" or "Post-update verificatio
|
|
|
186
195
|
|
|
187
196
|
### Step 6: Cleanup
|
|
188
197
|
|
|
189
|
-
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.
|
|
190
199
|
|
|
191
200
|
### Step 7: Commit
|
|
192
201
|
|
|
@@ -197,12 +206,24 @@ Where the commit lands was decided in Step 1 — the launcher's default branch i
|
|
|
197
206
|
git add -A
|
|
198
207
|
git commit -m "chore: update workspace from template v{fromVersion} to v{templateVersion}"
|
|
199
208
|
```
|
|
200
|
-
- **Remote exists:** from the task worktree created in Step 1, commit the applied update, push the branch, and open the PR/MR through `node
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
209
|
+
- **Remote exists:** from the task worktree created in Step 1, commit the applied update, push the branch, and open the PR/MR through `node {scripts}/task-pr.mjs` (`{scripts}` resolves as in Step 1 — the payload's copy whenever the workspace's own is missing). The workspace repo is addressed as `.`, so pass its PR body as `--body-file ".={path}"` (a repo with commits to merge but no body file is an error). If task-pr reports the forge unsupported, open the MR with the forge's own CLI (for GitLab, `glab mr create`) from the worktree and say so in the report. After the PR/MR merges, at the launcher and in this order:
|
|
210
|
+
1. Restore the bootstrapped skill — `--upgrade` replaced `.claude/skills/workspace-update/` in the launcher (a tracked modification) and the merged PR delivers the same content, so a dirty launcher blocks the pull. If `SKILL.md.local-backup` sits there (a customised skill the CLI backed up), move it somewhere safe first (e.g. `workspace-scratchpad/`), then:
|
|
211
|
+
```bash
|
|
212
|
+
git -C {launcher} checkout -- .claude/skills/workspace-update
|
|
213
|
+
git -C {launcher} clean -f -- .claude/skills/workspace-update
|
|
214
|
+
```
|
|
215
|
+
2. `git -C {launcher} pull --ff-only`
|
|
216
|
+
3. Rebuild the catalogs — the pull can delete the gitignored per-user `workspace-context/team-member/{user}/index.md` (tracked before this update, untracked by it) that `CLAUDE.local.md` imports:
|
|
217
|
+
```bash
|
|
218
|
+
node {launcher}/.claude/scripts/build-workspace-context.mjs --write --root {launcher}
|
|
219
|
+
```
|
|
220
|
+
4. Remove the update worktree and its local branch:
|
|
221
|
+
```bash
|
|
222
|
+
node {launcher}/.claude/scripts/task-worktree.mjs --root {launcher} --remove --repo . --branch chore/template-update-{version} --delete-branch
|
|
223
|
+
```
|
|
224
|
+
5. Delete the payload (Step 6).
|
|
225
|
+
|
|
226
|
+
Newly added skills appear only after this merge — start a new chat or `/reload` to pick them up.
|
|
206
227
|
|
|
207
228
|
Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules or hooks changed."
|
|
208
229
|
|
|
@@ -212,8 +233,8 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
|
|
|
212
233
|
|
|
213
234
|
## Notes
|
|
214
235
|
|
|
215
|
-
- 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),
|
|
216
|
-
- 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)
|
|
217
238
|
- Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys, and deliberately activated rules
|
|
218
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
|
|
219
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
|