@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.22.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/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/package.json +1 -1
- package/template/_claude/scripts/classify-update.mjs +283 -27
- package/template/_claude/scripts/maintenance-audit.mjs +0 -0
- package/template/_claude/scripts/template-baseline.mjs +215 -0
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +38 -14
package/lib/init.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, readdirSync, statSync } from 'fs';
|
|
3
3
|
import { join, basename } from 'path';
|
|
4
4
|
import { stagePayload } from './payload.mjs';
|
|
5
|
+
import { writeBaseline } from '../template/_claude/scripts/template-baseline.mjs';
|
|
5
6
|
|
|
6
7
|
function ensureDir(dir) {
|
|
7
8
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
@@ -127,6 +128,14 @@ export async function initWorkspace(targetDir) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// Record the template baseline — the sha256 of every verbatim payload file —
|
|
132
|
+
// so the first /workspace-update can tell template changes from local edits
|
|
133
|
+
// three ways (gh:183). Entries always hold the payload's content, so the
|
|
134
|
+
// components /workspace-init installs verbatim are already covered;
|
|
135
|
+
// /workspace-init refreshes the baseline after the full install anyway.
|
|
136
|
+
writeBaseline(targetDir, payloadDir, { version: toVersion });
|
|
137
|
+
console.log(' Wrote template baseline (.claude/.template-baseline.json)');
|
|
138
|
+
|
|
130
139
|
console.log(`
|
|
131
140
|
Workspace initialized (v${toVersion}).
|
|
132
141
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test for init.mjs (--init scaffolding)
|
|
3
|
+
// Run: node lib/init.test.mjs
|
|
4
|
+
//
|
|
5
|
+
// --init stages the template payload, installs the bootstrap skills plus
|
|
6
|
+
// hooks/scripts/lib, and must leave a template baseline behind: the sha256 of
|
|
7
|
+
// every verbatim payload file, so the first /workspace-update classifies
|
|
8
|
+
// three ways instead of treating every template change as a local edit
|
|
9
|
+
// (gh:183).
|
|
10
|
+
import { initWorkspace } from './init.mjs';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { dirname } from 'path';
|
|
17
|
+
|
|
18
|
+
let failed = 0;
|
|
19
|
+
let passed = 0;
|
|
20
|
+
function check(label, ok) {
|
|
21
|
+
if (ok) { passed++; } else {
|
|
22
|
+
failed++;
|
|
23
|
+
console.error(` FAIL: ${label}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const pkgVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
|
|
29
|
+
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
30
|
+
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), 'init-test-'));
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await initWorkspace(root);
|
|
35
|
+
|
|
36
|
+
const baselinePath = join(root, '.claude', '.template-baseline.json');
|
|
37
|
+
check('baseline written at .claude/.template-baseline.json', existsSync(baselinePath));
|
|
38
|
+
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
39
|
+
|
|
40
|
+
check(`baseline records the payload version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
|
|
41
|
+
check(
|
|
42
|
+
'baseline covers the bootstrap skills --init installs',
|
|
43
|
+
typeof baseline.files['.claude/skills/workspace-update/SKILL.md'] === 'string'
|
|
44
|
+
&& typeof baseline.files['.claude/skills/workspace-init/SKILL.md'] === 'string',
|
|
45
|
+
);
|
|
46
|
+
check(
|
|
47
|
+
'baseline covers hooks and scripts',
|
|
48
|
+
typeof baseline.files['.claude/hooks/session-start.mjs'] === 'string'
|
|
49
|
+
&& typeof baseline.files['.claude/scripts/classify-update.mjs'] === 'string',
|
|
50
|
+
);
|
|
51
|
+
check(
|
|
52
|
+
'baseline covers the standalone verbatim files',
|
|
53
|
+
typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
|
|
54
|
+
);
|
|
55
|
+
check(
|
|
56
|
+
'entry hash matches the installed file byte-for-byte',
|
|
57
|
+
baseline.files['.claude/scripts/classify-update.mjs'] === sha(join(root, '.claude', 'scripts', 'classify-update.mjs')),
|
|
58
|
+
);
|
|
59
|
+
check(
|
|
60
|
+
'baseline never covers tests (the tarball ships none)',
|
|
61
|
+
Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs')),
|
|
62
|
+
);
|
|
63
|
+
check(
|
|
64
|
+
'baseline never covers machine-local files',
|
|
65
|
+
!('.claude/settings.local.json' in baseline.files) && !('.claude/.active-session.json' in baseline.files),
|
|
66
|
+
);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (failed > 0) {
|
|
72
|
+
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
console.log(`${passed} checks passed`);
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, rmSync, statSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
+
import { writeBaseline, INERT_PAIRS } from '../template/_claude/scripts/template-baseline.mjs';
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const TEMPLATE_DIR = join(__dirname, '..', 'template');
|
|
@@ -98,5 +99,12 @@ export async function scaffold(answers) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Record the template baseline — the sha256 of every verbatim-installed
|
|
103
|
+
// file — so /workspace-update can classify three ways (workspace vs payload
|
|
104
|
+
// vs baseline) instead of treating every template change as a local edit
|
|
105
|
+
// (gh:183). The template tree carries the inert names; entries always hold
|
|
106
|
+
// the template's content, so hashes come straight from the source of truth.
|
|
107
|
+
writeBaseline(directory, TEMPLATE_DIR, { pairs: INERT_PAIRS, version: pkgJson.version });
|
|
108
|
+
|
|
101
109
|
return directory;
|
|
102
110
|
}
|
package/lib/scaffold.test.mjs
CHANGED
|
@@ -48,6 +48,26 @@ try {
|
|
|
48
48
|
check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
|
|
49
49
|
check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
|
|
50
50
|
check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
|
|
51
|
+
|
|
52
|
+
// Template baseline: hashes of the verbatim-installed files, for three-way
|
|
53
|
+
// update classification (gh:183)
|
|
54
|
+
const baselinePath = join(directory, '.claude', '.template-baseline.json');
|
|
55
|
+
check('template baseline written', existsSync(baselinePath));
|
|
56
|
+
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
57
|
+
const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
58
|
+
check(`baseline records the package version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
|
|
59
|
+
check(
|
|
60
|
+
'baseline covers rules installed verbatim from the template',
|
|
61
|
+
typeof baseline.files['.claude/rules/coherent-revisions.md'] === 'string',
|
|
62
|
+
);
|
|
63
|
+
check(
|
|
64
|
+
'baseline covers .mcp.json via its inert source name',
|
|
65
|
+
typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
|
|
66
|
+
);
|
|
67
|
+
check(
|
|
68
|
+
'baseline never covers tests or machine-local files',
|
|
69
|
+
Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs') && k !== '.claude/settings.local.json'),
|
|
70
|
+
);
|
|
51
71
|
} finally {
|
|
52
72
|
rmSync(directory, { recursive: true, force: true });
|
|
53
73
|
}
|
package/package.json
CHANGED
|
@@ -4,33 +4,80 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
6
|
// node classify-update.mjs [--root <dir>] [--payload <dir>]
|
|
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
10
|
// --root workspace root; defaults to the current working directory (never
|
|
9
11
|
// derived from this script's location — the upgrade payload runs
|
|
10
12
|
// this file from <workspace>/.workspace-update/.claude/scripts/)
|
|
11
13
|
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
12
14
|
//
|
|
13
|
-
//
|
|
14
|
-
// new
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
15
|
+
// The default mode prints JSON with these lists:
|
|
16
|
+
// new — no installed counterpart and no baseline entry; safe to
|
|
17
|
+
// batch-apply after one confirm
|
|
18
|
+
// identical — installed file already equals the payload
|
|
19
|
+
// updated — installed file equals the BASELINE (what the template last
|
|
20
|
+
// shipped here) but not the payload: a pure template change the
|
|
21
|
+
// user never touched. Batched with `new` behind one confirm.
|
|
22
|
+
// differs — installed file matches neither the payload nor the baseline
|
|
23
|
+
// while the payload also differs from the baseline: a local
|
|
24
|
+
// edit AND a template change — the one case that needs a
|
|
25
|
+
// per-file decision (or the workspace predates baselines and
|
|
26
|
+
// has no entry to compare).
|
|
27
|
+
// localOnly — installed file differs from the payload, but the payload
|
|
28
|
+
// equals the baseline: the template hasn't touched the file
|
|
29
|
+
// since the last update, so the difference is purely local.
|
|
30
|
+
// Listed for information only — never asked about, never
|
|
31
|
+
// applied.
|
|
32
|
+
// deletedLocally — the baseline records the file and the payload still
|
|
33
|
+
// ships it, but it is missing from the workspace: deleted
|
|
34
|
+
// locally (or never installed at /workspace-init). The skill
|
|
35
|
+
// asks once whether to restore the list.
|
|
36
|
+
// activated — the payload ships rules/{name}.md.skip while the workspace
|
|
37
|
+
// deliberately keeps {name}.md active; nothing to install, the
|
|
38
|
+
// active rule stays (gh:180)
|
|
39
|
+
// removed — installed file with no payload counterpart: the template
|
|
40
|
+
// stopped shipping it. Excludes what the workspace owns:
|
|
41
|
+
// *.test.mjs (see staleTests), anything gitignored
|
|
42
|
+
// (machine-local), paths under .claude/worktrees/, and entries
|
|
43
|
+
// of workspace.json → workspace.localFiles (array of
|
|
44
|
+
// .claude/-relative paths or globs for files this workspace
|
|
45
|
+
// owns) (gh:180)
|
|
46
|
+
// staleTests — *.test.mjs files under .claude/ with no payload counterpart.
|
|
47
|
+
// The npm tarball ships no tests, so these came from a dev
|
|
48
|
+
// checkout and are never updated by /workspace-update; the
|
|
49
|
+
// skill offers to remove them (tests live in the template repo)
|
|
50
|
+
//
|
|
51
|
+
// Plus `hasBaseline`: whether .claude/.template-baseline.json exists. Without
|
|
52
|
+
// it (workspaces older than the baseline's introduction) template changes
|
|
53
|
+
// cannot be told from local edits, so they land in `differs` — the first
|
|
54
|
+
// update after v0.21 asks per file; once it writes the baseline, later updates
|
|
55
|
+
// won't.
|
|
56
|
+
//
|
|
57
|
+
// Content comparisons hash with CRLF normalized to LF on both sides (binary
|
|
58
|
+
// files hash byte-exact), so a git autocrlf checkout that stores CRLF where
|
|
59
|
+
// the payload ships LF classifies as identical rather than locally modified.
|
|
28
60
|
//
|
|
29
61
|
// Only verbatim-installed files are classified: everything under .claude/,
|
|
30
62
|
// plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
|
|
31
63
|
// install with {{project-name}} substitution), _gitignore (merged line-by-line
|
|
32
64
|
// into the workspace's .gitignore), and .manifest.json (payload metadata) are
|
|
33
65
|
// handled by their own steps in /workspace-update and are excluded here.
|
|
66
|
+
//
|
|
67
|
+
// The other two modes are /workspace-update bookends:
|
|
68
|
+
// --write-baseline write .claude/.template-baseline.json recording the
|
|
69
|
+
// hash of every verbatim payload file — what the template
|
|
70
|
+
// now ships. Run at the END of an update, after all
|
|
71
|
+
// per-file decisions. Entries record the PAYLOAD hash —
|
|
72
|
+
// except unapplied updates (workspace still holds the old
|
|
73
|
+
// baseline content), which keep the old entry so they
|
|
74
|
+
// present as `updated` again next time; see
|
|
75
|
+
// template-baseline.mjs. Throws rather than writing an
|
|
76
|
+
// empty baseline.
|
|
77
|
+
// --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
|
|
78
|
+
// merged in: template lines updated, the workspace's own
|
|
79
|
+
// lines (custom skill entries, sections) kept. The skill
|
|
80
|
+
// shows the diff against the current file before writing.
|
|
34
81
|
|
|
35
82
|
import {
|
|
36
83
|
existsSync,
|
|
@@ -39,9 +86,10 @@ import {
|
|
|
39
86
|
statSync,
|
|
40
87
|
realpathSync,
|
|
41
88
|
} from 'node:fs';
|
|
42
|
-
import { join, resolve } from 'node:path';
|
|
89
|
+
import { basename, join, resolve } from 'node:path';
|
|
43
90
|
import { fileURLToPath } from 'node:url';
|
|
44
91
|
import { gitIgnoredPaths } from './build-workspace-context.mjs';
|
|
92
|
+
import { BASELINE_PATH, hashBytes, readBaseline, writeBaseline } from './template-baseline.mjs';
|
|
45
93
|
|
|
46
94
|
function isMainModule(metaUrl) {
|
|
47
95
|
if (!process.argv[1]) return false;
|
|
@@ -51,11 +99,13 @@ function isMainModule(metaUrl) {
|
|
|
51
99
|
}
|
|
52
100
|
|
|
53
101
|
function parseArgs(argv) {
|
|
54
|
-
const args = { root: process.cwd(), payload: null };
|
|
102
|
+
const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
|
|
55
103
|
for (let i = 2; i < argv.length; i++) {
|
|
56
104
|
const a = argv[i];
|
|
57
105
|
if (a === '--root') args.root = argv[++i];
|
|
58
106
|
else if (a === '--payload') args.payload = argv[++i];
|
|
107
|
+
else if (a === '--write-baseline') args.writeBaseline = true;
|
|
108
|
+
else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
|
|
59
109
|
else throw new Error(`Unknown arg: ${a}`);
|
|
60
110
|
}
|
|
61
111
|
return args;
|
|
@@ -136,9 +186,11 @@ function globMatches(pattern, rel) {
|
|
|
136
186
|
}
|
|
137
187
|
|
|
138
188
|
function isOwnedByWorkspace(rel, localFiles) {
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json')
|
|
189
|
+
// The template's own .gitignore declares these machine-local; the baseline
|
|
190
|
+
// is per-workspace state the template never ships.
|
|
191
|
+
if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
142
194
|
if (!rel.startsWith('.claude/')) return false;
|
|
143
195
|
const claudeRel = rel.slice('.claude/'.length);
|
|
144
196
|
return localFiles.some((pattern) => globMatches(pattern, claudeRel));
|
|
@@ -153,8 +205,20 @@ export function classifyUpdate({ root, payload }) {
|
|
|
153
205
|
|
|
154
206
|
const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
|
|
155
207
|
const payloadSet = new Set(payloadFiles);
|
|
208
|
+
const baseline = readBaseline(absRoot);
|
|
156
209
|
|
|
157
|
-
const result = {
|
|
210
|
+
const result = {
|
|
211
|
+
new: [],
|
|
212
|
+
identical: [],
|
|
213
|
+
updated: [],
|
|
214
|
+
differs: [],
|
|
215
|
+
localOnly: [],
|
|
216
|
+
deletedLocally: [],
|
|
217
|
+
activated: [],
|
|
218
|
+
removed: [],
|
|
219
|
+
staleTests: [],
|
|
220
|
+
hasBaseline: baseline !== null,
|
|
221
|
+
};
|
|
158
222
|
for (const rel of payloadFiles) {
|
|
159
223
|
// A .skip rule whose active counterpart is installed was deliberately
|
|
160
224
|
// activated by this workspace: report it as activated, not new.
|
|
@@ -167,14 +231,34 @@ export function classifyUpdate({ root, payload }) {
|
|
|
167
231
|
}
|
|
168
232
|
const installed = join(absRoot, rel);
|
|
169
233
|
if (!existsSync(installed)) {
|
|
170
|
-
|
|
234
|
+
// A file the baseline records and the payload still ships, yet missing
|
|
235
|
+
// from the workspace: deleted locally (or declined at install time) —
|
|
236
|
+
// not new, the template has carried it all along.
|
|
237
|
+
if (baseline && typeof baseline.files[rel] === 'string') {
|
|
238
|
+
result.deletedLocally.push(rel);
|
|
239
|
+
} else {
|
|
240
|
+
result.new.push(rel);
|
|
241
|
+
}
|
|
171
242
|
continue;
|
|
172
243
|
}
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
244
|
+
const wsHash = hashBytes(readFileSync(installed));
|
|
245
|
+
const payloadHash = hashBytes(readFileSync(join(absPayload, rel)));
|
|
246
|
+
if (wsHash === payloadHash) {
|
|
176
247
|
result.identical.push(rel);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const baseHash = baseline ? baseline.files[rel] : undefined;
|
|
251
|
+
if (baseHash !== undefined && wsHash === baseHash) {
|
|
252
|
+
// Workspace still holds exactly what the template last shipped here —
|
|
253
|
+
// the difference is the template's own change since then.
|
|
254
|
+
result.updated.push(rel);
|
|
255
|
+
} else if (baseHash !== undefined && payloadHash === baseHash) {
|
|
256
|
+
// The payload is unchanged since the baseline; the workspace's
|
|
257
|
+
// difference is purely local. Informational — nothing to apply.
|
|
258
|
+
result.localOnly.push(rel);
|
|
177
259
|
} else {
|
|
260
|
+
// A local edit on top of a template change (or no baseline entry to
|
|
261
|
+
// compare) — the one case that needs a per-file decision.
|
|
178
262
|
result.differs.push(rel);
|
|
179
263
|
}
|
|
180
264
|
}
|
|
@@ -190,16 +274,188 @@ export function classifyUpdate({ root, payload }) {
|
|
|
190
274
|
// not a removed one.
|
|
191
275
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
|
|
192
276
|
if (gitignored.has(rel)) continue;
|
|
277
|
+
// Test files never come from the npm tarball; the payload not carrying one
|
|
278
|
+
// means the template's test suite moved on without this copy.
|
|
279
|
+
if (rel.endsWith('.test.mjs')) {
|
|
280
|
+
result.staleTests.push(rel);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
193
283
|
if (isOwnedByWorkspace(rel, localFiles)) continue;
|
|
194
284
|
result.removed.push(rel);
|
|
195
285
|
}
|
|
196
286
|
return result;
|
|
197
287
|
}
|
|
198
288
|
|
|
289
|
+
// ---------- CLAUDE.md merge ----------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Split markdown into blocks: the preamble (heading null) plus one block per
|
|
293
|
+
* `## ` heading. Deeper headings belong to their enclosing section, and `## `
|
|
294
|
+
* lines inside fenced code blocks (``` or ~~~) stay content of their section.
|
|
295
|
+
*/
|
|
296
|
+
function splitBlocks(text) {
|
|
297
|
+
const blocks = [];
|
|
298
|
+
let cur = { heading: null, lines: [] };
|
|
299
|
+
let fenced = false;
|
|
300
|
+
for (const line of text.split(/\r?\n/)) {
|
|
301
|
+
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
|
|
302
|
+
if (!fenced && /^##\s/.test(line)) {
|
|
303
|
+
blocks.push(cur);
|
|
304
|
+
cur = { heading: line.trim(), lines: [] };
|
|
305
|
+
} else {
|
|
306
|
+
cur.lines.push(line);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
blocks.push(cur);
|
|
310
|
+
return blocks;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A heading's merge key. Identical headings match; beyond that, any
|
|
315
|
+
* `## Workspace:` heading matches any other — the intro heading carries the
|
|
316
|
+
* workspace name, which differs the moment a workspace is renamed (or the
|
|
317
|
+
* fallback directory name was used), and treating them as two sections
|
|
318
|
+
* duplicated the template's intro alongside the renamed original.
|
|
319
|
+
*/
|
|
320
|
+
function headingKey(heading) {
|
|
321
|
+
if (heading !== null && heading.startsWith('## Workspace:')) return '## Workspace:';
|
|
322
|
+
return heading;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A list entry's merge key: the name of its first backticked `/command`
|
|
327
|
+
* token (`- \`/start-work [handoff|blank]\` — …` → start-work). Two entries
|
|
328
|
+
* with the same name are the same skill, so the template's reworded line
|
|
329
|
+
* replaces the workspace's instead of duplicating it.
|
|
330
|
+
*/
|
|
331
|
+
function entryKey(line) {
|
|
332
|
+
const m = line.match(/^\s*[-*]\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/);
|
|
333
|
+
return m ? m[1] : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function trimTrailingBlanks(lines) {
|
|
337
|
+
let end = lines.length;
|
|
338
|
+
while (end > 0 && lines[end - 1].trim() === '') end--;
|
|
339
|
+
return lines.slice(0, end);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function trimLeadingBlanks(lines) {
|
|
343
|
+
let start = 0;
|
|
344
|
+
while (start < lines.length && lines[start].trim() === '') start++;
|
|
345
|
+
return lines.slice(start);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One section's bodies merged: the template's new lines, then the workspace's
|
|
350
|
+
* lines that the template no longer carries (matched by entry name for list
|
|
351
|
+
* entries, by trimmed text otherwise).
|
|
352
|
+
*/
|
|
353
|
+
function mergeBody(curLines, nxtLines) {
|
|
354
|
+
const nxtKeys = new Set(nxtLines.map(entryKey).filter(Boolean));
|
|
355
|
+
const nxtTrimmed = new Set(nxtLines.map((l) => l.trim()).filter(Boolean));
|
|
356
|
+
const kept = [];
|
|
357
|
+
for (const line of curLines) {
|
|
358
|
+
const key = entryKey(line);
|
|
359
|
+
if (key !== null && nxtKeys.has(key)) continue; // template owns this entry — its line updates ours
|
|
360
|
+
const t = line.trim();
|
|
361
|
+
if (t !== '' && nxtTrimmed.has(t)) continue; // unchanged line, already present
|
|
362
|
+
kept.push(line);
|
|
363
|
+
}
|
|
364
|
+
const body = trimTrailingBlanks(nxtLines);
|
|
365
|
+
return kept.length === 0 ? body : [...body, ...trimLeadingBlanks(trimTrailingBlanks(kept))];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderBlocks(blocks, eol) {
|
|
369
|
+
const parts = [];
|
|
370
|
+
for (const b of blocks) {
|
|
371
|
+
const body = trimTrailingBlanks(b.lines);
|
|
372
|
+
if (b.heading === null) {
|
|
373
|
+
if (body.length > 0) parts.push(body.join(eol));
|
|
374
|
+
} else {
|
|
375
|
+
parts.push([b.heading, ...body].join(eol));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return parts.join(eol + eol) + eol;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Merge an updated template CLAUDE.md (`nextText`, already {{project-name}}-
|
|
383
|
+
* substituted) into the workspace's current one. Template-owned lines take the
|
|
384
|
+
* template's new versions; lines the template doesn't have — the workspace's
|
|
385
|
+
* own skill entries, custom bullets, whole sections — are kept. Sections are
|
|
386
|
+
* matched by heading (`## Workspace:` headings match regardless of name): the
|
|
387
|
+
* result follows the workspace's section order, new template sections are
|
|
388
|
+
* appended at the end, and kept lines land at the end of their section. The
|
|
389
|
+
* output keeps the current file's line endings — CRLF in, CRLF out.
|
|
390
|
+
*/
|
|
391
|
+
export function mergeClaudeMd(currentText, nextText) {
|
|
392
|
+
const eol = currentText != null && currentText.includes('\r\n') ? '\r\n' : '\n';
|
|
393
|
+
const nxtBlocks = splitBlocks(nextText);
|
|
394
|
+
if (currentText == null || currentText.trim() === '') return renderBlocks(nxtBlocks, eol);
|
|
395
|
+
const nxtByHeading = new Map(nxtBlocks.map((b) => [headingKey(b.heading), b]));
|
|
396
|
+
const used = new Set();
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const cur of splitBlocks(currentText)) {
|
|
399
|
+
const nxt = nxtByHeading.get(headingKey(cur.heading));
|
|
400
|
+
if (nxt) {
|
|
401
|
+
used.add(nxt);
|
|
402
|
+
out.push({ heading: nxt.heading, lines: mergeBody(cur.lines, nxt.lines) });
|
|
403
|
+
} else {
|
|
404
|
+
out.push(cur); // a section the template doesn't have — the workspace's own
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const nxt of nxtBlocks) {
|
|
408
|
+
if (!used.has(nxt)) out.push({ heading: nxt.heading, lines: trimTrailingBlanks(nxt.lines) });
|
|
409
|
+
}
|
|
410
|
+
return renderBlocks(out, eol);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------- CLI modes ----------
|
|
414
|
+
|
|
415
|
+
function resolvePayload(args) {
|
|
416
|
+
return resolve(args.payload ?? join(resolve(args.root), '.workspace-update'));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeBaselineMode(args) {
|
|
420
|
+
const baseline = writeBaseline(args.root, resolvePayload(args));
|
|
421
|
+
process.stdout.write(JSON.stringify({
|
|
422
|
+
written: true,
|
|
423
|
+
path: BASELINE_PATH,
|
|
424
|
+
templateVersion: baseline.templateVersion,
|
|
425
|
+
files: Object.keys(baseline.files).length,
|
|
426
|
+
}, null, 2) + '\n');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function mergeClaudeMdMode(args) {
|
|
430
|
+
const absRoot = resolve(args.root);
|
|
431
|
+
const absPayload = resolvePayload(args);
|
|
432
|
+
const tmplPath = join(absPayload, 'CLAUDE.md.tmpl');
|
|
433
|
+
if (!existsSync(tmplPath)) {
|
|
434
|
+
throw new Error(`No CLAUDE.md.tmpl in ${absPayload} — nothing to merge`);
|
|
435
|
+
}
|
|
436
|
+
// The workspace name for {{project-name}} substitution: workspace.json is
|
|
437
|
+
// the source of truth; the directory name is the fallback.
|
|
438
|
+
let name = basename(absRoot);
|
|
439
|
+
try {
|
|
440
|
+
const config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
|
|
441
|
+
if (typeof config?.workspace?.name === 'string' && config.workspace.name) name = config.workspace.name;
|
|
442
|
+
} catch { /* no workspace.json — keep the directory name */ }
|
|
443
|
+
const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
|
|
444
|
+
const claudeMdPath = join(absRoot, 'CLAUDE.md');
|
|
445
|
+
const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
|
|
446
|
+
process.stdout.write(mergeClaudeMd(current, next));
|
|
447
|
+
}
|
|
448
|
+
|
|
199
449
|
function main() {
|
|
200
450
|
const args = parseArgs(process.argv);
|
|
201
|
-
|
|
202
|
-
|
|
451
|
+
if (args.writeBaseline) {
|
|
452
|
+
writeBaselineMode(args);
|
|
453
|
+
} else if (args.mergeClaudeMd) {
|
|
454
|
+
mergeClaudeMdMode(args);
|
|
455
|
+
} else {
|
|
456
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload });
|
|
457
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
458
|
+
}
|
|
203
459
|
}
|
|
204
460
|
|
|
205
461
|
if (isMainModule(import.meta.url)) {
|
|
Binary file
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Template baseline: the sha256 of every verbatim-installed file the template
|
|
3
|
+
// last shipped, recorded at <root>/.claude/.template-baseline.json. It is the
|
|
4
|
+
// third input of /workspace-update's three-way classification (workspace vs
|
|
5
|
+
// payload vs baseline), so a file the template changed since the installed
|
|
6
|
+
// version reads as an update to apply in batch — not a local edit to negotiate
|
|
7
|
+
// file by file (gh:183).
|
|
8
|
+
//
|
|
9
|
+
// Shape:
|
|
10
|
+
// { "templateVersion": "0.21.0",
|
|
11
|
+
// "files": { ".claude/skills/workspace-update/SKILL.md": "<sha256>", … } }
|
|
12
|
+
// Keys are root-relative posix paths covering the verbatim-installed roots
|
|
13
|
+
// (.claude/**, .mcp.json, .claudeignore). The file is committed with the
|
|
14
|
+
// workspace — every machine that pulls gets the same template files, so it
|
|
15
|
+
// gets the same baseline.
|
|
16
|
+
//
|
|
17
|
+
// Written by `--init` scaffolding (lib/init.mjs), by /workspace-init once the
|
|
18
|
+
// remaining components are installed, and at the end of every /workspace-update
|
|
19
|
+
// (classify-update.mjs --write-baseline). Interactive scaffolding writes it
|
|
20
|
+
// from the template tree (lib/scaffold.mjs).
|
|
21
|
+
//
|
|
22
|
+
// The rule every entry follows: record the hash of the payload's content —
|
|
23
|
+
// what the template last shipped — never the workspace's on-disk bytes. A
|
|
24
|
+
// file the user kept despite a template change therefore keeps the PAYLOAD
|
|
25
|
+
// hash: the next update sees workspace ≠ baseline with payload == baseline
|
|
26
|
+
// and reports the file as a purely local edit (informational, not re-asked
|
|
27
|
+
// per file) until the template touches it again, and a deliberate divergence
|
|
28
|
+
// is never silently adopted as the new baseline. One exception: an unapplied
|
|
29
|
+
// update — the workspace still holds the OLD baseline content while the
|
|
30
|
+
// payload ships something new (the user declined the `updated` batch) — keeps
|
|
31
|
+
// the old entry, so the file re-presents as `updated` next time instead of
|
|
32
|
+
// being filed away as a local edit. The invariant that matters either way: a
|
|
33
|
+
// file the user never touched (workspace == baseline) is never reported as a
|
|
34
|
+
// local edit.
|
|
35
|
+
//
|
|
36
|
+
// All hashes are CRLF-normalized for text files (see hashBytes): a Windows
|
|
37
|
+
// autocrlf checkout stores CRLF where the payload carries LF, and byte-exact
|
|
38
|
+
// hashing would read every such file as locally modified. Files containing
|
|
39
|
+
// NUL bytes hash byte-exact.
|
|
40
|
+
//
|
|
41
|
+
// Not covered: *.test.mjs (the tarball never ships them; a workspace's test
|
|
42
|
+
// files came from a dev checkout and age independently — classify-update
|
|
43
|
+
// reports them as staleTests instead), machine-local files
|
|
44
|
+
// (.claude/settings.local.json, .claude/.active-session.json), and anything
|
|
45
|
+
// under .claude/worktrees/.
|
|
46
|
+
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import {
|
|
49
|
+
existsSync,
|
|
50
|
+
mkdirSync,
|
|
51
|
+
readFileSync,
|
|
52
|
+
readdirSync,
|
|
53
|
+
statSync,
|
|
54
|
+
writeFileSync,
|
|
55
|
+
} from 'node:fs';
|
|
56
|
+
import { dirname, join, resolve } from 'node:path';
|
|
57
|
+
|
|
58
|
+
export const BASELINE_PATH = '.claude/.template-baseline.json';
|
|
59
|
+
|
|
60
|
+
// [source name, installed name] pairs for the verbatim-installed roots. The
|
|
61
|
+
// staged payload carries the live names; the template tree stores .claude/ and
|
|
62
|
+
// .mcp.json under the inert names _claude/ and _mcp.json, so scaffold passes
|
|
63
|
+
// the inert pairs.
|
|
64
|
+
export const LIVE_PAIRS = [
|
|
65
|
+
['.claude', '.claude'],
|
|
66
|
+
['.mcp.json', '.mcp.json'],
|
|
67
|
+
['.claudeignore', '.claudeignore'],
|
|
68
|
+
];
|
|
69
|
+
export const INERT_PAIRS = [
|
|
70
|
+
['_claude', '.claude'],
|
|
71
|
+
['_mcp.json', '.mcp.json'],
|
|
72
|
+
['.claudeignore', '.claudeignore'],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Machine-local or per-workspace files that never belong in the baseline.
|
|
76
|
+
const OWNED_PATHS = new Set([
|
|
77
|
+
'.claude/settings.local.json',
|
|
78
|
+
'.claude/.active-session.json',
|
|
79
|
+
BASELINE_PATH,
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// Entire nested worktrees live under .claude/worktrees/ — never walked.
|
|
83
|
+
const SKIP_DIRS = new Set(['worktrees']);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The content hash used by the baseline and by classification: sha256 with
|
|
87
|
+
* CRLF normalized to LF for text files, byte-exact for binary (anything
|
|
88
|
+
* containing a NUL byte — git's own text/binary heuristic). Both the payload
|
|
89
|
+
* and the workspace side hash through this, so a git autocrlf checkout that
|
|
90
|
+
* stores CRLF where the payload ships LF classifies as identical instead of
|
|
91
|
+
* reading every file as locally modified.
|
|
92
|
+
*/
|
|
93
|
+
export function hashBytes(bytes) {
|
|
94
|
+
let body = bytes;
|
|
95
|
+
if (!bytes.includes(0)) {
|
|
96
|
+
// latin1 round-trips bytes 1:1 — safe on text that isn't valid UTF-8 too.
|
|
97
|
+
body = Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
98
|
+
}
|
|
99
|
+
return createHash('sha256').update(body).digest('hex');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function* walkFiles(dir, prefix = '') {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir).sort();
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const name of entries) {
|
|
110
|
+
if (prefix === '.claude' && SKIP_DIRS.has(name)) continue;
|
|
111
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
112
|
+
const full = join(dir, name);
|
|
113
|
+
let st;
|
|
114
|
+
try { st = statSync(full); } catch { continue; }
|
|
115
|
+
if (st.isDirectory()) yield* walkFiles(full, rel);
|
|
116
|
+
else if (st.isFile()) yield rel;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the baseline at <root>/.claude/.template-baseline.json. Returns
|
|
122
|
+
* { templateVersion, files } or null when absent (workspaces older than the
|
|
123
|
+
* baseline's introduction) or unparseable — classification then falls back to
|
|
124
|
+
* the two-way behavior rather than failing the update.
|
|
125
|
+
*/
|
|
126
|
+
export function readBaseline(root) {
|
|
127
|
+
const path = join(resolve(root), BASELINE_PATH);
|
|
128
|
+
if (!existsSync(path)) return null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
131
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object' || parsed.files === null) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { templateVersion: typeof parsed.templateVersion === 'string' ? parsed.templateVersion : null, files: parsed.files };
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a baseline from a template source directory (a staged payload with
|
|
142
|
+
* live names, or the template tree via INERT_PAIRS). `version` overrides the
|
|
143
|
+
* payload manifest's templateVersion — pass it when the source has no
|
|
144
|
+
* .manifest.json (the template tree).
|
|
145
|
+
*/
|
|
146
|
+
export function buildBaseline(sourceDir, { pairs = LIVE_PAIRS, version = null } = {}) {
|
|
147
|
+
const absSource = resolve(sourceDir);
|
|
148
|
+
const files = {};
|
|
149
|
+
for (const [sourceName, installedName] of pairs) {
|
|
150
|
+
const src = join(absSource, sourceName);
|
|
151
|
+
if (!existsSync(src)) continue;
|
|
152
|
+
let st;
|
|
153
|
+
try { st = statSync(src); } catch { continue; }
|
|
154
|
+
if (st.isFile()) {
|
|
155
|
+
if (!OWNED_PATHS.has(installedName) && !installedName.endsWith('.test.mjs')) {
|
|
156
|
+
files[installedName] = hashBytes(readFileSync(src));
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const rel of walkFiles(src, installedName)) {
|
|
161
|
+
if (OWNED_PATHS.has(rel) || rel.endsWith('.test.mjs')) continue;
|
|
162
|
+
files[rel] = hashBytes(readFileSync(join(absSource, sourceName, rel.slice(installedName.length + 1))));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let templateVersion = version;
|
|
166
|
+
if (templateVersion === null) {
|
|
167
|
+
try {
|
|
168
|
+
const manifest = JSON.parse(readFileSync(join(absSource, '.manifest.json'), 'utf8'));
|
|
169
|
+
if (typeof manifest.templateVersion === 'string') templateVersion = manifest.templateVersion;
|
|
170
|
+
} catch {
|
|
171
|
+
// No manifest (or unreadable) — caller should pass `version`.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { templateVersion, files: Object.fromEntries(Object.keys(files).sort().map((k) => [k, files[k]])) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write the baseline for the workspace at `root`. Returns the written object.
|
|
179
|
+
*
|
|
180
|
+
* Refuses to write an empty baseline: a missing source directory or one that
|
|
181
|
+
* yields zero verbatim files throws rather than clobbering an existing good
|
|
182
|
+
* baseline with `{files:{}}` (which would make the next update classify every
|
|
183
|
+
* template change as a local edit).
|
|
184
|
+
*
|
|
185
|
+
* Before writing, entries carried over from the previous baseline are kept as
|
|
186
|
+
* they were for unapplied updates: a file whose workspace content still
|
|
187
|
+
* matches the old baseline while the payload ships something new (a declined
|
|
188
|
+
* `updated` batch) keeps the OLD entry, so the next update still offers the
|
|
189
|
+
* change instead of filing the file away as a local edit.
|
|
190
|
+
*/
|
|
191
|
+
export function writeBaseline(root, sourceDir, opts = {}) {
|
|
192
|
+
const absRoot = resolve(root);
|
|
193
|
+
const baseline = buildBaseline(sourceDir, opts);
|
|
194
|
+
if (Object.keys(baseline.files).length === 0) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`No verbatim template files found under ${resolve(sourceDir)} — refusing to write an empty baseline`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const previous = readBaseline(absRoot);
|
|
200
|
+
if (previous) {
|
|
201
|
+
for (const rel of Object.keys(baseline.files)) {
|
|
202
|
+
const oldHash = previous.files[rel];
|
|
203
|
+
if (typeof oldHash !== 'string' || oldHash === baseline.files[rel]) continue;
|
|
204
|
+
const installed = join(absRoot, rel);
|
|
205
|
+
if (existsSync(installed) && hashBytes(readFileSync(installed)) === oldHash) {
|
|
206
|
+
// Unapplied update: the workspace never took the payload's change.
|
|
207
|
+
baseline.files[rel] = oldHash;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const dest = join(absRoot, BASELINE_PATH);
|
|
212
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
213
|
+
writeFileSync(dest, JSON.stringify(baseline, null, 2) + '\n');
|
|
214
|
+
return baseline;
|
|
215
|
+
}
|
|
@@ -126,6 +126,12 @@ Also install these top-level files from the payload:
|
|
|
126
126
|
|
|
127
127
|
**Per-repo CLAUDE.md stubs:** For each repo in `workspace.json`, check if `repos/{repo}/CLAUDE.md` exists. If not, ask "Scaffold a CLAUDE.md for {repo}? [Y/n]". If yes, write a blank stub from `.workspace-update/repo-claude.md.tmpl`, substituting `{{repo-name}}` with the repo name. The stub body is comment text only — no workspace-specific content — and its `## Commands` section is where you will add repo-specific test, lint, and build commands.
|
|
128
128
|
|
|
129
|
+
**Write the template baseline** now that the components are installed, so future `/workspace-update` runs can tell template changes from local edits (three-way classification):
|
|
130
|
+
```bash
|
|
131
|
+
node .claude/scripts/classify-update.mjs --root . --write-baseline --payload .workspace-update
|
|
132
|
+
```
|
|
133
|
+
It hashes the payload's verbatim files — run it before Step 15 deletes the payload.
|
|
134
|
+
|
|
129
135
|
**Commit:** `git commit -m "feat: install template components from payload"`
|
|
130
136
|
|
|
131
137
|
### Step 6: Activate optional rules
|
|
@@ -37,28 +37,36 @@ In the commands below, `{payload}` is `.workspace-update` in the no-remote flow
|
|
|
37
37
|
|
|
38
38
|
### Step 2: Classify the payload
|
|
39
39
|
|
|
40
|
-
Run the classifier — it compares every verbatim-installed payload file (`.claude/**`, `.mcp.json`, `.claudeignore`) against the workspace
|
|
40
|
+
Run the classifier — it compares every verbatim-installed payload file (`.claude/**`, `.mcp.json`, `.claudeignore`) against the workspace and the template baseline (`.claude/.template-baseline.json`, the hashes of what the template last shipped here), and detects files the template no longer ships:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
It runs from the payload precisely so workspaces that don't have it installed yet can use it; once installed, `node .claude/scripts/classify-update.mjs --root .` is equivalent. Output is JSON
|
|
46
|
+
It runs from the payload precisely so workspaces that don't have it installed yet can use it; once installed, `node .claude/scripts/classify-update.mjs --root .` is equivalent. Output is JSON:
|
|
47
47
|
|
|
48
|
-
- `new` — no installed counterpart; safe to batch-apply (Step 3) behind one confirmation
|
|
48
|
+
- `new` — no installed counterpart and no baseline entry; safe to batch-apply (Step 3) behind one confirmation
|
|
49
49
|
- `identical` — installed file already equals the payload; skip silently
|
|
50
|
-
- `
|
|
50
|
+
- `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
|
|
51
|
+
- `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
|
|
52
|
+
- `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
|
|
53
|
+
- `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
|
|
51
54
|
- `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.
|
|
52
|
-
- `removed` — installed file with no counterpart in the payload.
|
|
55
|
+
- `removed` — installed file with no counterpart in the payload. 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, so only real template removals are listed.
|
|
56
|
+
- `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).
|
|
57
|
+
|
|
58
|
+
Content is compared with line endings normalized (CRLF ≡ LF; binary files byte-exact), so a Windows autocrlf checkout does not read as locally modified.
|
|
59
|
+
|
|
60
|
+
If `hasBaseline` is false (the workspace predates v0.21), tell the user: "No template baseline — this first update asks about every changed file individually; once it finishes and writes the baseline (Step 4), later updates won't." Template changes then land in `differs`.
|
|
53
61
|
|
|
54
62
|
Templates (`*.tmpl`, which install with `{{project-name}}` substitution), `_gitignore` (merged line-by-line), and `.manifest.json` (payload metadata) are not classified — each is handled by its own sub-step in Step 3.
|
|
55
63
|
|
|
56
64
|
Report with version info from the manifest:
|
|
57
65
|
```
|
|
58
|
-
"Template update: v{fromVersion} → v{templateVersion}. {N} new files, {M} locally modified, {A} activated rules, {R} removed files, {K} unchanged."
|
|
66
|
+
"Template update: v{fromVersion} → v{templateVersion}. {N} new files, {U} template-updated, {M} locally modified, {L} local-only edits, {D} deleted locally, {A} activated rules, {R} removed files, {K} unchanged."
|
|
59
67
|
```
|
|
60
68
|
|
|
61
|
-
If `new`, `differs`, `activated`, and `removed` are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed."
|
|
69
|
+
If `new`, `updated`, `differs`, `deletedLocally`, `activated`, and `removed` 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.)
|
|
62
70
|
|
|
63
71
|
### Step 2b: Historical .gitignore safety check
|
|
64
72
|
|
|
@@ -79,25 +87,40 @@ Commit the fix **before** applying other template updates. This runs ahead of St
|
|
|
79
87
|
|
|
80
88
|
### Step 3: Selective update
|
|
81
89
|
|
|
82
|
-
Batch the safe
|
|
90
|
+
Batch the safe cases, ask on the rest:
|
|
83
91
|
|
|
84
|
-
- **New files (`new`):** present
|
|
85
|
-
- **Locally modified (`differs`):** ask per file — "
|
|
92
|
+
- **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.
|
|
93
|
+
- **Locally modified (`differs`):** ask per file — "Your version of {file} differs from the template's. Show diff? [y/N]" — then apply, keep, or merge per the user's decision.
|
|
94
|
+
- **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.
|
|
95
|
+
- **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.
|
|
86
96
|
- **Removed in template (`removed`):** "Template removed {file}. Delete locally? [y/N]" — conservative default.
|
|
87
97
|
- **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
|
|
98
|
+
- **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.
|
|
88
99
|
- **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
|
|
89
100
|
|
|
90
101
|
Also handle these non-component files from the payload:
|
|
91
102
|
|
|
92
103
|
- **settings.json:** Merge payload values into existing `.claude/settings.json` — do not overwrite user customizations. Add new keys, update hook commands if hooks were migrated, preserve user-added entries.
|
|
93
104
|
- **workspace.json keys:** Compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
|
|
94
|
-
- **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists,
|
|
105
|
+
- **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, merge — never regenerate from scratch:
|
|
106
|
+
```bash
|
|
107
|
+
node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --merge-claude-md
|
|
108
|
+
```
|
|
109
|
+
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.
|
|
95
110
|
- **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines.
|
|
96
111
|
|
|
97
|
-
### Step 4: Update version
|
|
112
|
+
### Step 4: Update version and write the baseline
|
|
98
113
|
|
|
99
114
|
Read `templateVersion` from `.workspace-update/.manifest.json` and update `templateVersion` in `workspace.json` to match.
|
|
100
115
|
|
|
116
|
+
Then write the template baseline so the NEXT update classifies three ways instead of asking per file:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --write-baseline
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Run it after every Step 3 decision has been made (it runs from the payload because the workspace's own copy may predate this update). It records the hash of every verbatim payload file — what the template now ships — with one deliberate exception: a file whose update was declined (the workspace still holds the old baseline content while the payload ships something new) keeps the OLD entry, so the change is offered again as `updated` next time instead of being filed away. Everything else records the payload hash: a file the user kept in their own version reads as `localOnly` (informational) until the template changes it again, and a file nobody touched never reads as a local edit. The command refuses to write an empty baseline — if it errors, the payload path is wrong; do not force it.
|
|
123
|
+
|
|
101
124
|
### Step 4a: Run idempotent migrators
|
|
102
125
|
|
|
103
126
|
Two migrators run on **every** update — both idempotent, safe on already-migrated workspaces. Run each and surface its action in the upgrade summary.
|
|
@@ -138,7 +161,7 @@ node .claude/scripts/build-workspace-context.mjs --check --root .
|
|
|
138
161
|
|
|
139
162
|
`--check` must exit clean. If it reports drift, re-run `--write` and check again before continuing.
|
|
140
163
|
|
|
141
|
-
Then run the scripted audit — once, covering everything (gh:180). Build the changed list — every file this update added or changed: the applied `new` and `differs` files plus the merged ones (`CLAUDE.md`, `workspace.json`, `.gitignore`, `.claude/settings.json`, `.mcp.json` as touched) — one path per line into a temp file such as `workspace-scratchpad/update-changed.txt`, then:
|
|
164
|
+
Then run the scripted audit — once, covering everything (gh:180). Build the changed list — every file this update added or changed: the applied `new`, `updated`, and `differs` files, any restored `deletedLocally` files, plus the merged ones (`CLAUDE.md`, `workspace.json`, `.gitignore`, `.claude/settings.json`, `.mcp.json` as touched) — one path per line into a temp file such as `workspace-scratchpad/update-changed.txt`, then:
|
|
142
165
|
|
|
143
166
|
```bash
|
|
144
167
|
node {payload}/.claude/scripts/maintenance-audit.mjs --root . --changed workspace-scratchpad/update-changed.txt
|
|
@@ -178,8 +201,9 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
|
|
|
178
201
|
## Notes
|
|
179
202
|
|
|
180
203
|
- The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload. This skill processes it.
|
|
181
|
-
- Never overwrites without asking — `new` files are batched behind one confirmation; `differs` files are asked per file
|
|
204
|
+
- Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are asked per file; `localOnly` files are never asked about (local edits to files the template didn't touch)
|
|
182
205
|
- Preserves local modifications, custom content, existing `workspace.json` keys, and deliberately activated rules
|
|
206
|
+
- 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
|
|
183
207
|
- The launcher's default branch takes a template-update commit only when the workspace has no remote; with a remote, the update lands through a task worktree and a PR
|
|
184
208
|
- Can be run multiple times safely (idempotent) — if `.workspace-update/` doesn't exist, it reports no payload and exits
|
|
185
209
|
- Initial setup is handled by `npx @ulysses-ai/create-workspace --init` + `/workspace-init` — this skill is for subsequent updates only
|