@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.23.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +474 -38
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/maintenance-audit.mjs +0 -0
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +239 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +63 -27
|
@@ -3,34 +3,110 @@
|
|
|
3
3
|
// can batch the safe cases and ask only where a decision is needed.
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// node classify-update.mjs [--root <dir>] [--payload <dir>]
|
|
6
|
+
// node classify-update.mjs [--root <dir>] [--payload <dir>] [--baseline <file>]
|
|
7
|
+
// node classify-update.mjs --root <dir> --payload <dir> --write-baseline
|
|
8
|
+
// node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
|
|
7
9
|
//
|
|
8
|
-
// --root
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// --payload
|
|
10
|
+
// --root workspace root; defaults to the current working directory (never
|
|
11
|
+
// derived from this script's location — the upgrade payload runs
|
|
12
|
+
// this file from <workspace>/.workspace-update/.claude/scripts/)
|
|
13
|
+
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
14
|
+
// --baseline the baseline to classify against; defaults to
|
|
15
|
+
// <root>/.claude/.template-baseline.json, falling back to
|
|
16
|
+
// <payload>/.template-baseline.reconstructed.json (what --upgrade
|
|
17
|
+
// reconstructs for pre-baseline workspaces) when the root has none.
|
|
18
|
+
// Pass it explicitly in the worktree flow, where <root> is the
|
|
19
|
+
// worktree and the launcher's baseline may not be reachable.
|
|
12
20
|
//
|
|
13
|
-
//
|
|
14
|
-
// new
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
21
|
+
// The default mode prints JSON with these lists:
|
|
22
|
+
// new — no installed counterpart and no baseline entry; safe to
|
|
23
|
+
// batch-apply after one confirm
|
|
24
|
+
// identical — installed file already equals the payload
|
|
25
|
+
// updated — installed file equals the BASELINE (what the template last
|
|
26
|
+
// shipped here) but not the payload: a pure template change the
|
|
27
|
+
// user never touched. Batched with `new` behind one confirm.
|
|
28
|
+
// differs — installed file matches neither the payload nor the baseline
|
|
29
|
+
// while the payload also differs from the baseline: a local
|
|
30
|
+
// edit AND a template change — the one case that needs a
|
|
31
|
+
// per-file decision (or the workspace predates baselines and
|
|
32
|
+
// has no entry to compare).
|
|
33
|
+
// config — .mcp.json and .claude/settings.json: JSON the workspace
|
|
34
|
+
// owns jointly with the template (its own MCP servers and
|
|
35
|
+
// settings live beside template keys). Never classified by
|
|
36
|
+
// content and never batch-copied — instead each entry carries
|
|
37
|
+
// a key-level diff (`added` keys the template ships, keys
|
|
38
|
+
// `workspaceOnly`, keys `changed` in both, nested paths joined
|
|
39
|
+
// with '/'), and /workspace-update merges key by key: add
|
|
40
|
+
// template keys, keep workspace-only keys, ask on conflicting
|
|
41
|
+
// keys. Array-valued keys (hooks event lists,
|
|
42
|
+
// permissions.allow/deny) diff by ELEMENT instead of whole:
|
|
43
|
+
// each `arrays` entry is { path, added, workspaceOnly } with
|
|
44
|
+
// the element lists, and the skill merges arrays as a union —
|
|
45
|
+
// the workspace's elements kept, the template's new ones
|
|
46
|
+
// appended — so only true scalar conflicts ask. Entries flag
|
|
47
|
+
// `notInstalled` (no workspace file — ask once whether to
|
|
48
|
+
// install the payload's copy) or `unparseable` (broken JSON on
|
|
49
|
+
// either side — ask, never merge blind).
|
|
50
|
+
// localOnly — installed file differs from the payload, but the payload
|
|
51
|
+
// equals the baseline: the template hasn't touched the file
|
|
52
|
+
// since the last update, so the difference is purely local.
|
|
53
|
+
// Listed for information only — never asked about, never
|
|
54
|
+
// applied.
|
|
55
|
+
// deletedLocally — the baseline records the file and the payload still
|
|
56
|
+
// ships it, but it is missing from the workspace: deleted
|
|
57
|
+
// locally (or never installed at /workspace-init). The skill
|
|
58
|
+
// asks once whether to restore the list.
|
|
59
|
+
// activated — the payload ships rules/{name}.md.skip while the workspace
|
|
60
|
+
// deliberately keeps {name}.md active; nothing to install, the
|
|
61
|
+
// active rule stays (gh:180)
|
|
62
|
+
// removed — installed file with no payload counterpart: the template
|
|
63
|
+
// stopped shipping it. Excludes what the workspace owns:
|
|
64
|
+
// the config files above (the template dropping one hands it
|
|
65
|
+
// to the workspace, it never deletes user content),
|
|
66
|
+
// *.test.mjs (see staleTests), anything gitignored
|
|
67
|
+
// (machine-local), paths under .claude/worktrees/, and entries
|
|
68
|
+
// of workspace.json → workspace.localFiles (array of
|
|
69
|
+
// .claude/-relative paths or globs for files this workspace
|
|
70
|
+
// owns) (gh:180)
|
|
71
|
+
// staleTests — *.test.mjs files under .claude/ with no payload counterpart.
|
|
72
|
+
// The npm tarball ships no tests, so these came from a dev
|
|
73
|
+
// checkout and are never updated by /workspace-update; the
|
|
74
|
+
// skill offers to remove them (tests live in the template repo)
|
|
28
75
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
76
|
+
// Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
|
|
77
|
+
// (which file it came from) and `baselineReconstructed`. The default
|
|
78
|
+
// resolution is <root>/.claude/.template-baseline.json, then the payload's
|
|
79
|
+
// .template-baseline.reconstructed.json (both unparseable-as-absent); without
|
|
80
|
+
// either, template changes cannot be told from local edits, so they land in
|
|
81
|
+
// `differs` — the first update asks per file; once it writes the baseline,
|
|
82
|
+
// later updates won't.
|
|
83
|
+
//
|
|
84
|
+
// Content comparisons hash with CRLF normalized to LF on both sides (binary
|
|
85
|
+
// files hash byte-exact), so a git autocrlf checkout that stores CRLF where
|
|
86
|
+
// the payload ships LF classifies as identical rather than locally modified.
|
|
87
|
+
//
|
|
88
|
+
// Only verbatim-installed files are classified: everything under .claude/
|
|
89
|
+
// except .claude/settings.json, plus .mcp.json and .claudeignore — the two
|
|
90
|
+
// JSON configs route to `config` instead of the content lists. The payload's
|
|
91
|
+
// templates (*.tmpl, which install with {{project-name}} substitution),
|
|
92
|
+
// _gitignore (merged line-by-line into the workspace's .gitignore), and
|
|
93
|
+
// .manifest.json (payload metadata) are handled by their own steps in
|
|
94
|
+
// /workspace-update and are excluded here.
|
|
95
|
+
//
|
|
96
|
+
// The other two modes are /workspace-update bookends:
|
|
97
|
+
// --write-baseline write .claude/.template-baseline.json recording the
|
|
98
|
+
// hash of every verbatim payload file — what the template
|
|
99
|
+
// now ships. Run at the END of an update, after all
|
|
100
|
+
// per-file decisions. Entries record the PAYLOAD hash —
|
|
101
|
+
// except unapplied updates (workspace still holds the old
|
|
102
|
+
// baseline content), which keep the old entry so they
|
|
103
|
+
// present as `updated` again next time; see
|
|
104
|
+
// template-baseline.mjs. Throws rather than writing an
|
|
105
|
+
// empty baseline.
|
|
106
|
+
// --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
|
|
107
|
+
// merged in: template lines updated, the workspace's own
|
|
108
|
+
// lines (custom skill entries, sections) kept. The skill
|
|
109
|
+
// shows the diff against the current file before writing.
|
|
34
110
|
|
|
35
111
|
import {
|
|
36
112
|
existsSync,
|
|
@@ -39,9 +115,16 @@ import {
|
|
|
39
115
|
statSync,
|
|
40
116
|
realpathSync,
|
|
41
117
|
} from 'node:fs';
|
|
42
|
-
import { join, resolve } from 'node:path';
|
|
118
|
+
import { basename, join, resolve } from 'node:path';
|
|
43
119
|
import { fileURLToPath } from 'node:url';
|
|
44
120
|
import { gitIgnoredPaths } from './build-workspace-context.mjs';
|
|
121
|
+
import {
|
|
122
|
+
BASELINE_PATH,
|
|
123
|
+
RECONSTRUCTED_BASELINE_NAME,
|
|
124
|
+
hashBytes,
|
|
125
|
+
readBaselineFile,
|
|
126
|
+
writeBaseline,
|
|
127
|
+
} from './template-baseline.mjs';
|
|
45
128
|
|
|
46
129
|
function isMainModule(metaUrl) {
|
|
47
130
|
if (!process.argv[1]) return false;
|
|
@@ -51,11 +134,14 @@ function isMainModule(metaUrl) {
|
|
|
51
134
|
}
|
|
52
135
|
|
|
53
136
|
function parseArgs(argv) {
|
|
54
|
-
const args = { root: process.cwd(), payload: null };
|
|
137
|
+
const args = { root: process.cwd(), payload: null, baseline: null, writeBaseline: false, mergeClaudeMd: false };
|
|
55
138
|
for (let i = 2; i < argv.length; i++) {
|
|
56
139
|
const a = argv[i];
|
|
57
140
|
if (a === '--root') args.root = argv[++i];
|
|
58
141
|
else if (a === '--payload') args.payload = argv[++i];
|
|
142
|
+
else if (a === '--baseline') args.baseline = argv[++i];
|
|
143
|
+
else if (a === '--write-baseline') args.writeBaseline = true;
|
|
144
|
+
else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
|
|
59
145
|
else throw new Error(`Unknown arg: ${a}`);
|
|
60
146
|
}
|
|
61
147
|
return args;
|
|
@@ -65,6 +151,104 @@ function parseArgs(argv) {
|
|
|
65
151
|
// Everything else in the payload is a template or metadata handled elsewhere.
|
|
66
152
|
const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
|
|
67
153
|
|
|
154
|
+
// JSON configs the workspace owns jointly with the template: its own MCP
|
|
155
|
+
// servers sit inside .mcp.json's mcpServers, its own settings beside the
|
|
156
|
+
// template's keys in .claude/settings.json. Content classification would
|
|
157
|
+
// file every one of them as `differs` the moment the workspace adds
|
|
158
|
+
// anything, and a batch copy would wipe the workspace's entries — so they
|
|
159
|
+
// are reported in `config` with a key-level diff and merged key by key,
|
|
160
|
+
// never compared by bytes and never copied wholesale (gh:186).
|
|
161
|
+
const CONFIG_PATHS = new Set(['.mcp.json', '.claude/settings.json']);
|
|
162
|
+
|
|
163
|
+
function isPlainObject(value) {
|
|
164
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Key-level diff between the payload's config object and the workspace's.
|
|
169
|
+
* Paths join keys with '/' (mcpServers/playwright) and stop at two
|
|
170
|
+
* segments: these configs are maps of named units — mcpServers/{server},
|
|
171
|
+
* permissions/{allow} — and a unit's own internals (a server's args vs
|
|
172
|
+
* command) merge as one decision, not as separate asks. Arrays the key
|
|
173
|
+
* carries on both sides diff by ELEMENT (a union merge needs no decision),
|
|
174
|
+
* and any other non-object value compares by JSON value and reports at its
|
|
175
|
+
* unit's path.
|
|
176
|
+
*/
|
|
177
|
+
const CONFIG_DIFF_DEPTH = 2;
|
|
178
|
+
|
|
179
|
+
function arrayElementDiff(payloadArr, workspaceArr) {
|
|
180
|
+
const wsSet = new Set(workspaceArr.map((e) => JSON.stringify(e)));
|
|
181
|
+
const plSet = new Set(payloadArr.map((e) => JSON.stringify(e)));
|
|
182
|
+
return {
|
|
183
|
+
added: payloadArr.filter((e) => !wsSet.has(JSON.stringify(e))),
|
|
184
|
+
workspaceOnly: workspaceArr.filter((e) => !plSet.has(JSON.stringify(e))),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function configKeyDiff(payloadObj, workspaceObj, prefix = '') {
|
|
189
|
+
const added = [];
|
|
190
|
+
const workspaceOnly = [];
|
|
191
|
+
const changed = [];
|
|
192
|
+
const arrays = [];
|
|
193
|
+
const keys = new Set([...Object.keys(payloadObj), ...Object.keys(workspaceObj)]);
|
|
194
|
+
for (const key of [...keys].sort()) {
|
|
195
|
+
const path = prefix ? `${prefix}/${key}` : key;
|
|
196
|
+
const inPayload = Object.prototype.hasOwnProperty.call(payloadObj, key);
|
|
197
|
+
const inWorkspace = Object.prototype.hasOwnProperty.call(workspaceObj, key);
|
|
198
|
+
if (inPayload && !inWorkspace) { added.push(path); continue; }
|
|
199
|
+
if (!inPayload && inWorkspace) { workspaceOnly.push(path); continue; }
|
|
200
|
+
const pv = payloadObj[key];
|
|
201
|
+
const wv = workspaceObj[key];
|
|
202
|
+
if (
|
|
203
|
+
prefix.split('/').filter(Boolean).length + 1 < CONFIG_DIFF_DEPTH
|
|
204
|
+
&& isPlainObject(pv) && isPlainObject(wv)
|
|
205
|
+
) {
|
|
206
|
+
const sub = configKeyDiff(pv, wv, path);
|
|
207
|
+
added.push(...sub.added);
|
|
208
|
+
workspaceOnly.push(...sub.workspaceOnly);
|
|
209
|
+
changed.push(...sub.changed);
|
|
210
|
+
arrays.push(...sub.arrays);
|
|
211
|
+
} else if (Array.isArray(pv) && Array.isArray(wv)) {
|
|
212
|
+
// An array both sides hold is a set the workspace extends: element
|
|
213
|
+
// lists let the skill union-merge instead of choosing one side whole.
|
|
214
|
+
const diff = arrayElementDiff(pv, wv);
|
|
215
|
+
if (diff.added.length > 0 || diff.workspaceOnly.length > 0) {
|
|
216
|
+
arrays.push({ path, ...diff });
|
|
217
|
+
}
|
|
218
|
+
} else if (JSON.stringify(pv) !== JSON.stringify(wv)) {
|
|
219
|
+
changed.push(path);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { added, workspaceOnly, changed, arrays };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One `config` entry: the key-level diff for a payload-shipped config file
|
|
227
|
+
* against the workspace's copy, or a flag when no diff is possible —
|
|
228
|
+
* `notInstalled` (no workspace file; the skill asks once whether to install
|
|
229
|
+
* the payload's copy) and `unparseable` (broken JSON on either side; the
|
|
230
|
+
* skill asks rather than merging blind).
|
|
231
|
+
*/
|
|
232
|
+
function configEntry(absRoot, absPayload, rel) {
|
|
233
|
+
let payloadJson;
|
|
234
|
+
try {
|
|
235
|
+
payloadJson = JSON.parse(readFileSync(join(absPayload, rel), 'utf8'));
|
|
236
|
+
} catch {
|
|
237
|
+
return { path: rel, unparseable: true };
|
|
238
|
+
}
|
|
239
|
+
if (!isPlainObject(payloadJson)) return { path: rel, unparseable: true };
|
|
240
|
+
const installed = join(absRoot, rel);
|
|
241
|
+
if (!existsSync(installed)) return { path: rel, notInstalled: true };
|
|
242
|
+
let workspaceJson;
|
|
243
|
+
try {
|
|
244
|
+
workspaceJson = JSON.parse(readFileSync(installed, 'utf8'));
|
|
245
|
+
} catch {
|
|
246
|
+
return { path: rel, unparseable: true };
|
|
247
|
+
}
|
|
248
|
+
if (!isPlainObject(workspaceJson)) return { path: rel, unparseable: true };
|
|
249
|
+
return { path: rel, ...configKeyDiff(payloadJson, workspaceJson) };
|
|
250
|
+
}
|
|
251
|
+
|
|
68
252
|
function isClassified(payloadRelPath) {
|
|
69
253
|
const first = payloadRelPath.split('/')[0];
|
|
70
254
|
return VERBATIM_ROOTS.includes(first);
|
|
@@ -136,15 +320,41 @@ function globMatches(pattern, rel) {
|
|
|
136
320
|
}
|
|
137
321
|
|
|
138
322
|
function isOwnedByWorkspace(rel, localFiles) {
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json')
|
|
323
|
+
// The template's own .gitignore declares these machine-local; the baseline
|
|
324
|
+
// is per-workspace state the template never ships.
|
|
325
|
+
if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
142
328
|
if (!rel.startsWith('.claude/')) return false;
|
|
143
329
|
const claudeRel = rel.slice('.claude/'.length);
|
|
144
330
|
return localFiles.some((pattern) => globMatches(pattern, claudeRel));
|
|
145
331
|
}
|
|
146
332
|
|
|
147
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Which baseline the classification runs against. An explicit --baseline
|
|
335
|
+
* wins; otherwise the workspace's own <root>/.claude/.template-baseline.json
|
|
336
|
+
* is tried first, then the payload's .template-baseline.reconstructed.json
|
|
337
|
+
* (staged by --upgrade for workspaces that predate baselines). The fallback
|
|
338
|
+
* matters in the worktree flow: <root> is the task worktree, which cannot
|
|
339
|
+
* see launcher-only files, while the payload travels there by absolute path.
|
|
340
|
+
* A file that exists but does not parse counts as absent — a corrupt
|
|
341
|
+
* baseline must not block the reconstructed one (gh:186).
|
|
342
|
+
*/
|
|
343
|
+
export function resolveBaseline({ root, payload, baseline = null }) {
|
|
344
|
+
const candidates = baseline !== null
|
|
345
|
+
? [{ path: resolve(baseline), label: baseline }]
|
|
346
|
+
: [
|
|
347
|
+
{ path: join(resolve(root), BASELINE_PATH), label: BASELINE_PATH },
|
|
348
|
+
{ path: join(resolve(payload), RECONSTRUCTED_BASELINE_NAME), label: `.workspace-update/${RECONSTRUCTED_BASELINE_NAME}` },
|
|
349
|
+
];
|
|
350
|
+
for (const candidate of candidates) {
|
|
351
|
+
const parsed = readBaselineFile(candidate.path);
|
|
352
|
+
if (parsed !== null) return { baseline: parsed, source: candidate.label };
|
|
353
|
+
}
|
|
354
|
+
return { baseline: null, source: null };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
|
|
148
358
|
const absRoot = resolve(root);
|
|
149
359
|
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
150
360
|
if (!existsSync(absPayload)) {
|
|
@@ -153,9 +363,30 @@ export function classifyUpdate({ root, payload }) {
|
|
|
153
363
|
|
|
154
364
|
const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
|
|
155
365
|
const payloadSet = new Set(payloadFiles);
|
|
366
|
+
const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
|
|
156
367
|
|
|
157
|
-
const result = {
|
|
368
|
+
const result = {
|
|
369
|
+
new: [],
|
|
370
|
+
identical: [],
|
|
371
|
+
updated: [],
|
|
372
|
+
differs: [],
|
|
373
|
+
config: [],
|
|
374
|
+
localOnly: [],
|
|
375
|
+
deletedLocally: [],
|
|
376
|
+
activated: [],
|
|
377
|
+
removed: [],
|
|
378
|
+
staleTests: [],
|
|
379
|
+
hasBaseline: baseline !== null,
|
|
380
|
+
baselineSource: source,
|
|
381
|
+
baselineReconstructed: baseline !== null && baseline.reconstructed === true,
|
|
382
|
+
};
|
|
158
383
|
for (const rel of payloadFiles) {
|
|
384
|
+
// Jointly-owned JSON configs never compare by content — the config
|
|
385
|
+
// list carries a key-level diff for the skill to merge instead.
|
|
386
|
+
if (CONFIG_PATHS.has(rel)) {
|
|
387
|
+
result.config.push(configEntry(absRoot, absPayload, rel));
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
159
390
|
// A .skip rule whose active counterpart is installed was deliberately
|
|
160
391
|
// activated by this workspace: report it as activated, not new.
|
|
161
392
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
|
|
@@ -167,14 +398,34 @@ export function classifyUpdate({ root, payload }) {
|
|
|
167
398
|
}
|
|
168
399
|
const installed = join(absRoot, rel);
|
|
169
400
|
if (!existsSync(installed)) {
|
|
170
|
-
|
|
401
|
+
// A file the baseline records and the payload still ships, yet missing
|
|
402
|
+
// from the workspace: deleted locally (or declined at install time) —
|
|
403
|
+
// not new, the template has carried it all along.
|
|
404
|
+
if (baseline && typeof baseline.files[rel] === 'string') {
|
|
405
|
+
result.deletedLocally.push(rel);
|
|
406
|
+
} else {
|
|
407
|
+
result.new.push(rel);
|
|
408
|
+
}
|
|
171
409
|
continue;
|
|
172
410
|
}
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
411
|
+
const wsHash = hashBytes(readFileSync(installed));
|
|
412
|
+
const payloadHash = hashBytes(readFileSync(join(absPayload, rel)));
|
|
413
|
+
if (wsHash === payloadHash) {
|
|
176
414
|
result.identical.push(rel);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const baseHash = baseline ? baseline.files[rel] : undefined;
|
|
418
|
+
if (baseHash !== undefined && wsHash === baseHash) {
|
|
419
|
+
// Workspace still holds exactly what the template last shipped here —
|
|
420
|
+
// the difference is the template's own change since then.
|
|
421
|
+
result.updated.push(rel);
|
|
422
|
+
} else if (baseHash !== undefined && payloadHash === baseHash) {
|
|
423
|
+
// The payload is unchanged since the baseline; the workspace's
|
|
424
|
+
// difference is purely local. Informational — nothing to apply.
|
|
425
|
+
result.localOnly.push(rel);
|
|
177
426
|
} else {
|
|
427
|
+
// A local edit on top of a template change (or no baseline entry to
|
|
428
|
+
// compare) — the one case that needs a per-file decision.
|
|
178
429
|
result.differs.push(rel);
|
|
179
430
|
}
|
|
180
431
|
}
|
|
@@ -186,20 +437,205 @@ export function classifyUpdate({ root, payload }) {
|
|
|
186
437
|
const gitignored = gitIgnoredPaths(absRoot, installedFiles);
|
|
187
438
|
for (const rel of installedFiles) {
|
|
188
439
|
if (skipSet.has(rel)) continue;
|
|
440
|
+
// A config file the payload dropped stays with the workspace: it holds
|
|
441
|
+
// user content the template never deletes.
|
|
442
|
+
if (CONFIG_PATHS.has(rel)) continue;
|
|
189
443
|
// An active rule whose .skip twin is in the payload is an activated rule,
|
|
190
444
|
// not a removed one.
|
|
191
445
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
|
|
192
446
|
if (gitignored.has(rel)) continue;
|
|
447
|
+
// Test files never come from the npm tarball; the payload not carrying one
|
|
448
|
+
// means the template's test suite moved on without this copy.
|
|
449
|
+
if (rel.endsWith('.test.mjs')) {
|
|
450
|
+
result.staleTests.push(rel);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
193
453
|
if (isOwnedByWorkspace(rel, localFiles)) continue;
|
|
194
454
|
result.removed.push(rel);
|
|
195
455
|
}
|
|
196
456
|
return result;
|
|
197
457
|
}
|
|
198
458
|
|
|
459
|
+
// ---------- CLAUDE.md merge ----------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Split markdown into blocks: the preamble (heading null) plus one block per
|
|
463
|
+
* `## ` heading. Deeper headings belong to their enclosing section, and `## `
|
|
464
|
+
* lines inside fenced code blocks (``` or ~~~) stay content of their section.
|
|
465
|
+
*/
|
|
466
|
+
function splitBlocks(text) {
|
|
467
|
+
const blocks = [];
|
|
468
|
+
let cur = { heading: null, lines: [] };
|
|
469
|
+
let fenced = false;
|
|
470
|
+
for (const line of text.split(/\r?\n/)) {
|
|
471
|
+
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
|
|
472
|
+
if (!fenced && /^##\s/.test(line)) {
|
|
473
|
+
blocks.push(cur);
|
|
474
|
+
cur = { heading: line.trim(), lines: [] };
|
|
475
|
+
} else {
|
|
476
|
+
cur.lines.push(line);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
blocks.push(cur);
|
|
480
|
+
return blocks;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* A heading's merge key. Identical headings match; beyond that, any
|
|
485
|
+
* `## Workspace:` heading matches any other — the intro heading carries the
|
|
486
|
+
* workspace name, which differs the moment a workspace is renamed (or the
|
|
487
|
+
* fallback directory name was used), and treating them as two sections
|
|
488
|
+
* duplicated the template's intro alongside the renamed original.
|
|
489
|
+
*/
|
|
490
|
+
function headingKey(heading) {
|
|
491
|
+
if (heading !== null && heading.startsWith('## Workspace:')) return '## Workspace:';
|
|
492
|
+
return heading;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A list entry's merge key: the name of its first backticked `/command`
|
|
497
|
+
* token (`- \`/start-work [handoff|blank]\` — …` → start-work). Two entries
|
|
498
|
+
* with the same name are the same skill, so the template's reworded line
|
|
499
|
+
* replaces the workspace's instead of duplicating it.
|
|
500
|
+
*/
|
|
501
|
+
function entryKey(line) {
|
|
502
|
+
const m = line.match(/^\s*[-*]\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/);
|
|
503
|
+
return m ? m[1] : null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function trimTrailingBlanks(lines) {
|
|
507
|
+
let end = lines.length;
|
|
508
|
+
while (end > 0 && lines[end - 1].trim() === '') end--;
|
|
509
|
+
return lines.slice(0, end);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function trimLeadingBlanks(lines) {
|
|
513
|
+
let start = 0;
|
|
514
|
+
while (start < lines.length && lines[start].trim() === '') start++;
|
|
515
|
+
return lines.slice(start);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* One section's bodies merged: the template's new lines, then the workspace's
|
|
520
|
+
* lines that the template no longer carries (matched by entry name for list
|
|
521
|
+
* entries, by trimmed text otherwise).
|
|
522
|
+
*/
|
|
523
|
+
function mergeBody(curLines, nxtLines) {
|
|
524
|
+
const nxtKeys = new Set(nxtLines.map(entryKey).filter(Boolean));
|
|
525
|
+
const nxtTrimmed = new Set(nxtLines.map((l) => l.trim()).filter(Boolean));
|
|
526
|
+
const kept = [];
|
|
527
|
+
for (const line of curLines) {
|
|
528
|
+
const key = entryKey(line);
|
|
529
|
+
if (key !== null && nxtKeys.has(key)) continue; // template owns this entry — its line updates ours
|
|
530
|
+
const t = line.trim();
|
|
531
|
+
if (t !== '' && nxtTrimmed.has(t)) continue; // unchanged line, already present
|
|
532
|
+
kept.push(line);
|
|
533
|
+
}
|
|
534
|
+
const body = trimTrailingBlanks(nxtLines);
|
|
535
|
+
return kept.length === 0 ? body : [...body, ...trimLeadingBlanks(trimTrailingBlanks(kept))];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function renderBlocks(blocks, eol) {
|
|
539
|
+
const parts = [];
|
|
540
|
+
for (const b of blocks) {
|
|
541
|
+
const body = trimTrailingBlanks(b.lines);
|
|
542
|
+
if (b.heading === null) {
|
|
543
|
+
if (body.length > 0) parts.push(body.join(eol));
|
|
544
|
+
} else {
|
|
545
|
+
parts.push([b.heading, ...body].join(eol));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return parts.join(eol + eol) + eol;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Merge an updated template CLAUDE.md (`nextText`, already {{project-name}}-
|
|
553
|
+
* substituted) into the workspace's current one. Template-owned lines take the
|
|
554
|
+
* template's new versions; lines the template doesn't have — the workspace's
|
|
555
|
+
* own skill entries, custom bullets, whole sections — are kept. Sections are
|
|
556
|
+
* matched by heading (`## Workspace:` headings match regardless of name): the
|
|
557
|
+
* result follows the workspace's section order, new template sections are
|
|
558
|
+
* appended at the end, and kept lines land at the end of their section. The
|
|
559
|
+
* output keeps the current file's line endings — CRLF in, CRLF out.
|
|
560
|
+
*/
|
|
561
|
+
export function mergeClaudeMd(currentText, nextText) {
|
|
562
|
+
const eol = currentText != null && currentText.includes('\r\n') ? '\r\n' : '\n';
|
|
563
|
+
const nxtBlocks = splitBlocks(nextText);
|
|
564
|
+
if (currentText == null || currentText.trim() === '') return renderBlocks(nxtBlocks, eol);
|
|
565
|
+
const nxtByHeading = new Map(nxtBlocks.map((b) => [headingKey(b.heading), b]));
|
|
566
|
+
const used = new Set();
|
|
567
|
+
const out = [];
|
|
568
|
+
for (const cur of splitBlocks(currentText)) {
|
|
569
|
+
const nxt = nxtByHeading.get(headingKey(cur.heading));
|
|
570
|
+
if (nxt) {
|
|
571
|
+
used.add(nxt);
|
|
572
|
+
out.push({ heading: nxt.heading, lines: mergeBody(cur.lines, nxt.lines) });
|
|
573
|
+
} else {
|
|
574
|
+
out.push(cur); // a section the template doesn't have — the workspace's own
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
for (const nxt of nxtBlocks) {
|
|
578
|
+
if (!used.has(nxt)) out.push({ heading: nxt.heading, lines: trimTrailingBlanks(nxt.lines) });
|
|
579
|
+
}
|
|
580
|
+
return renderBlocks(out, eol);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------- CLI modes ----------
|
|
584
|
+
|
|
585
|
+
function resolvePayload(args) {
|
|
586
|
+
return resolve(args.payload ?? join(resolve(args.root), '.workspace-update'));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function writeBaselineMode(args) {
|
|
590
|
+
const absPayload = resolvePayload(args);
|
|
591
|
+
// The previous baseline decides which declined updates keep their old
|
|
592
|
+
// entry — resolve it exactly as classification does, so the worktree flow
|
|
593
|
+
// (no baseline of its own yet) carries over from the payload's
|
|
594
|
+
// reconstructed one instead of starting from nothing.
|
|
595
|
+
const { baseline: previous } = resolveBaseline({
|
|
596
|
+
root: args.root,
|
|
597
|
+
payload: absPayload,
|
|
598
|
+
baseline: args.baseline,
|
|
599
|
+
});
|
|
600
|
+
const baseline = writeBaseline(args.root, absPayload, { previous });
|
|
601
|
+
process.stdout.write(JSON.stringify({
|
|
602
|
+
written: true,
|
|
603
|
+
path: BASELINE_PATH,
|
|
604
|
+
templateVersion: baseline.templateVersion,
|
|
605
|
+
files: Object.keys(baseline.files).length,
|
|
606
|
+
}, null, 2) + '\n');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function mergeClaudeMdMode(args) {
|
|
610
|
+
const absRoot = resolve(args.root);
|
|
611
|
+
const absPayload = resolvePayload(args);
|
|
612
|
+
const tmplPath = join(absPayload, 'CLAUDE.md.tmpl');
|
|
613
|
+
if (!existsSync(tmplPath)) {
|
|
614
|
+
throw new Error(`No CLAUDE.md.tmpl in ${absPayload} — nothing to merge`);
|
|
615
|
+
}
|
|
616
|
+
// The workspace name for {{project-name}} substitution: workspace.json is
|
|
617
|
+
// the source of truth; the directory name is the fallback.
|
|
618
|
+
let name = basename(absRoot);
|
|
619
|
+
try {
|
|
620
|
+
const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
|
|
621
|
+
if (typeof config?.workspace?.name === 'string' && config.workspace.name) name = config.workspace.name;
|
|
622
|
+
} catch { /* no workspace.json — keep the directory name */ }
|
|
623
|
+
const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
|
|
624
|
+
const claudeMdPath = join(absRoot, 'CLAUDE.md');
|
|
625
|
+
const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
|
|
626
|
+
process.stdout.write(mergeClaudeMd(current, next));
|
|
627
|
+
}
|
|
628
|
+
|
|
199
629
|
function main() {
|
|
200
630
|
const args = parseArgs(process.argv);
|
|
201
|
-
|
|
202
|
-
|
|
631
|
+
if (args.writeBaseline) {
|
|
632
|
+
writeBaselineMode(args);
|
|
633
|
+
} else if (args.mergeClaudeMd) {
|
|
634
|
+
mergeClaudeMdMode(args);
|
|
635
|
+
} else {
|
|
636
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload, baseline: args.baseline });
|
|
637
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
638
|
+
}
|
|
203
639
|
}
|
|
204
640
|
|
|
205
641
|
if (isMainModule(import.meta.url)) {
|