@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.1-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/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- 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 +356 -28
- 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 +47 -8
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +322 -32
- 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 +33 -9
- 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 +28 -8
- 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-update/SKILL.md +51 -26
|
@@ -3,14 +3,20 @@
|
|
|
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
7
|
// node classify-update.mjs --root <dir> --payload <dir> --write-baseline
|
|
8
8
|
// node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
|
|
9
9
|
//
|
|
10
|
-
// --root
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// --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.
|
|
14
20
|
//
|
|
15
21
|
// The default mode prints JSON with these lists:
|
|
16
22
|
// new — no installed counterpart and no baseline entry; safe to
|
|
@@ -24,6 +30,23 @@
|
|
|
24
30
|
// edit AND a template change — the one case that needs a
|
|
25
31
|
// per-file decision (or the workspace predates baselines and
|
|
26
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).
|
|
27
50
|
// localOnly — installed file differs from the payload, but the payload
|
|
28
51
|
// equals the baseline: the template hasn't touched the file
|
|
29
52
|
// since the last update, so the difference is purely local.
|
|
@@ -38,31 +61,51 @@
|
|
|
38
61
|
// active rule stays (gh:180)
|
|
39
62
|
// removed — installed file with no payload counterpart: the template
|
|
40
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),
|
|
41
66
|
// *.test.mjs (see staleTests), anything gitignored
|
|
42
67
|
// (machine-local), paths under .claude/worktrees/, and entries
|
|
43
68
|
// of workspace.json → workspace.localFiles (array of
|
|
44
69
|
// .claude/-relative paths or globs for files this workspace
|
|
45
|
-
// owns) (gh:180)
|
|
70
|
+
// owns) (gh:180). Two markers refine the per-file offer
|
|
71
|
+
// (gh:190): `{ file, referencedBy }` — a removed hook that a
|
|
72
|
+
// workspace-only settings.json entry still registers (the
|
|
73
|
+
// config-diff paths); the skill removes file and settings
|
|
74
|
+
// entry together. `{ file, userOwned: true }` — no baseline
|
|
75
|
+
// entry, so the template never shipped it: the workspace's
|
|
76
|
+
// own, offered a workspace.localFiles entry, not deletion.
|
|
46
77
|
// staleTests — *.test.mjs files under .claude/ with no payload counterpart.
|
|
47
78
|
// The npm tarball ships no tests, so these came from a dev
|
|
48
79
|
// checkout and are never updated by /workspace-update; the
|
|
49
80
|
// skill offers to remove them (tests live in the template repo)
|
|
81
|
+
// implicitDefaults — workspace.json keys whose ABSENCE carried a default
|
|
82
|
+
// in the version being upgraded FROM but not in the payload's:
|
|
83
|
+
// canonicalBudgetBytes meant a 40960-byte budget when absent
|
|
84
|
+
// from v0.15.0-beta.1 until v0.19.0-beta.0 made it opt-in
|
|
85
|
+
// (absent since means off; before v0.15 there was no budget).
|
|
86
|
+
// An upgrade from inside that window into a workspace.json
|
|
87
|
+
// without the key reports { key, value, reason } so the skill
|
|
88
|
+
// writes the value explicitly (gh:190).
|
|
50
89
|
//
|
|
51
|
-
// Plus `hasBaseline`: whether
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
90
|
+
// Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
|
|
91
|
+
// (which file it came from) and `baselineReconstructed`. The default
|
|
92
|
+
// resolution is <root>/.claude/.template-baseline.json, then the payload's
|
|
93
|
+
// .template-baseline.reconstructed.json (both unparseable-as-absent); without
|
|
94
|
+
// either, template changes cannot be told from local edits, so they land in
|
|
95
|
+
// `differs` — the first update asks per file; once it writes the baseline,
|
|
96
|
+
// later updates won't.
|
|
56
97
|
//
|
|
57
98
|
// Content comparisons hash with CRLF normalized to LF on both sides (binary
|
|
58
99
|
// files hash byte-exact), so a git autocrlf checkout that stores CRLF where
|
|
59
100
|
// the payload ships LF classifies as identical rather than locally modified.
|
|
60
101
|
//
|
|
61
|
-
// Only verbatim-installed files are classified: everything under .claude
|
|
62
|
-
// plus .mcp.json and .claudeignore
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
102
|
+
// Only verbatim-installed files are classified: everything under .claude/
|
|
103
|
+
// except .claude/settings.json, plus .mcp.json and .claudeignore — the two
|
|
104
|
+
// JSON configs route to `config` instead of the content lists. The payload's
|
|
105
|
+
// templates (*.tmpl, which install with {{project-name}} substitution),
|
|
106
|
+
// _gitignore (merged line-by-line into the workspace's .gitignore), and
|
|
107
|
+
// .manifest.json (payload metadata) are handled by their own steps in
|
|
108
|
+
// /workspace-update and are excluded here.
|
|
66
109
|
//
|
|
67
110
|
// The other two modes are /workspace-update bookends:
|
|
68
111
|
// --write-baseline write .claude/.template-baseline.json recording the
|
|
@@ -74,10 +117,15 @@
|
|
|
74
117
|
// present as `updated` again next time; see
|
|
75
118
|
// template-baseline.mjs. Throws rather than writing an
|
|
76
119
|
// empty baseline.
|
|
77
|
-
// --merge-claude-md print
|
|
78
|
-
//
|
|
79
|
-
// lines
|
|
80
|
-
//
|
|
120
|
+
// --merge-claude-md print JSON { claudeMd, missingIncludes }: CLAUDE.md
|
|
121
|
+
// with the payload's CLAUDE.md.tmpl merged in — template
|
|
122
|
+
// lines updated, the workspace's own lines (custom skill
|
|
123
|
+
// entries, sections) kept — plus the `@{path}` include
|
|
124
|
+
// lines the merged file carries whose targets don't
|
|
125
|
+
// exist at the root (machine-local local-only-* targets
|
|
126
|
+
// exempt). The skill shows the diff against the current
|
|
127
|
+
// file before writing, and asks on each missing include
|
|
128
|
+
// instead of leaving it dangling (gh:190).
|
|
81
129
|
|
|
82
130
|
import {
|
|
83
131
|
existsSync,
|
|
@@ -89,7 +137,14 @@ import {
|
|
|
89
137
|
import { basename, join, resolve } from 'node:path';
|
|
90
138
|
import { fileURLToPath } from 'node:url';
|
|
91
139
|
import { gitIgnoredPaths } from './build-workspace-context.mjs';
|
|
92
|
-
import {
|
|
140
|
+
import { compareVersions } from '../lib/registry-check.mjs';
|
|
141
|
+
import {
|
|
142
|
+
BASELINE_PATH,
|
|
143
|
+
RECONSTRUCTED_BASELINE_NAME,
|
|
144
|
+
hashBytes,
|
|
145
|
+
readBaselineFile,
|
|
146
|
+
writeBaseline,
|
|
147
|
+
} from './template-baseline.mjs';
|
|
93
148
|
|
|
94
149
|
function isMainModule(metaUrl) {
|
|
95
150
|
if (!process.argv[1]) return false;
|
|
@@ -99,11 +154,12 @@ function isMainModule(metaUrl) {
|
|
|
99
154
|
}
|
|
100
155
|
|
|
101
156
|
function parseArgs(argv) {
|
|
102
|
-
const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
|
|
157
|
+
const args = { root: process.cwd(), payload: null, baseline: null, writeBaseline: false, mergeClaudeMd: false };
|
|
103
158
|
for (let i = 2; i < argv.length; i++) {
|
|
104
159
|
const a = argv[i];
|
|
105
160
|
if (a === '--root') args.root = argv[++i];
|
|
106
161
|
else if (a === '--payload') args.payload = argv[++i];
|
|
162
|
+
else if (a === '--baseline') args.baseline = argv[++i];
|
|
107
163
|
else if (a === '--write-baseline') args.writeBaseline = true;
|
|
108
164
|
else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
|
|
109
165
|
else throw new Error(`Unknown arg: ${a}`);
|
|
@@ -115,6 +171,104 @@ function parseArgs(argv) {
|
|
|
115
171
|
// Everything else in the payload is a template or metadata handled elsewhere.
|
|
116
172
|
const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
|
|
117
173
|
|
|
174
|
+
// JSON configs the workspace owns jointly with the template: its own MCP
|
|
175
|
+
// servers sit inside .mcp.json's mcpServers, its own settings beside the
|
|
176
|
+
// template's keys in .claude/settings.json. Content classification would
|
|
177
|
+
// file every one of them as `differs` the moment the workspace adds
|
|
178
|
+
// anything, and a batch copy would wipe the workspace's entries — so they
|
|
179
|
+
// are reported in `config` with a key-level diff and merged key by key,
|
|
180
|
+
// never compared by bytes and never copied wholesale (gh:186).
|
|
181
|
+
const CONFIG_PATHS = new Set(['.mcp.json', '.claude/settings.json']);
|
|
182
|
+
|
|
183
|
+
function isPlainObject(value) {
|
|
184
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Key-level diff between the payload's config object and the workspace's.
|
|
189
|
+
* Paths join keys with '/' (mcpServers/playwright) and stop at two
|
|
190
|
+
* segments: these configs are maps of named units — mcpServers/{server},
|
|
191
|
+
* permissions/{allow} — and a unit's own internals (a server's args vs
|
|
192
|
+
* command) merge as one decision, not as separate asks. Arrays the key
|
|
193
|
+
* carries on both sides diff by ELEMENT (a union merge needs no decision),
|
|
194
|
+
* and any other non-object value compares by JSON value and reports at its
|
|
195
|
+
* unit's path.
|
|
196
|
+
*/
|
|
197
|
+
const CONFIG_DIFF_DEPTH = 2;
|
|
198
|
+
|
|
199
|
+
function arrayElementDiff(payloadArr, workspaceArr) {
|
|
200
|
+
const wsSet = new Set(workspaceArr.map((e) => JSON.stringify(e)));
|
|
201
|
+
const plSet = new Set(payloadArr.map((e) => JSON.stringify(e)));
|
|
202
|
+
return {
|
|
203
|
+
added: payloadArr.filter((e) => !wsSet.has(JSON.stringify(e))),
|
|
204
|
+
workspaceOnly: workspaceArr.filter((e) => !plSet.has(JSON.stringify(e))),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function configKeyDiff(payloadObj, workspaceObj, prefix = '') {
|
|
209
|
+
const added = [];
|
|
210
|
+
const workspaceOnly = [];
|
|
211
|
+
const changed = [];
|
|
212
|
+
const arrays = [];
|
|
213
|
+
const keys = new Set([...Object.keys(payloadObj), ...Object.keys(workspaceObj)]);
|
|
214
|
+
for (const key of [...keys].sort()) {
|
|
215
|
+
const path = prefix ? `${prefix}/${key}` : key;
|
|
216
|
+
const inPayload = Object.prototype.hasOwnProperty.call(payloadObj, key);
|
|
217
|
+
const inWorkspace = Object.prototype.hasOwnProperty.call(workspaceObj, key);
|
|
218
|
+
if (inPayload && !inWorkspace) { added.push(path); continue; }
|
|
219
|
+
if (!inPayload && inWorkspace) { workspaceOnly.push(path); continue; }
|
|
220
|
+
const pv = payloadObj[key];
|
|
221
|
+
const wv = workspaceObj[key];
|
|
222
|
+
if (
|
|
223
|
+
prefix.split('/').filter(Boolean).length + 1 < CONFIG_DIFF_DEPTH
|
|
224
|
+
&& isPlainObject(pv) && isPlainObject(wv)
|
|
225
|
+
) {
|
|
226
|
+
const sub = configKeyDiff(pv, wv, path);
|
|
227
|
+
added.push(...sub.added);
|
|
228
|
+
workspaceOnly.push(...sub.workspaceOnly);
|
|
229
|
+
changed.push(...sub.changed);
|
|
230
|
+
arrays.push(...sub.arrays);
|
|
231
|
+
} else if (Array.isArray(pv) && Array.isArray(wv)) {
|
|
232
|
+
// An array both sides hold is a set the workspace extends: element
|
|
233
|
+
// lists let the skill union-merge instead of choosing one side whole.
|
|
234
|
+
const diff = arrayElementDiff(pv, wv);
|
|
235
|
+
if (diff.added.length > 0 || diff.workspaceOnly.length > 0) {
|
|
236
|
+
arrays.push({ path, ...diff });
|
|
237
|
+
}
|
|
238
|
+
} else if (JSON.stringify(pv) !== JSON.stringify(wv)) {
|
|
239
|
+
changed.push(path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { added, workspaceOnly, changed, arrays };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One `config` entry: the key-level diff for a payload-shipped config file
|
|
247
|
+
* against the workspace's copy, or a flag when no diff is possible —
|
|
248
|
+
* `notInstalled` (no workspace file; the skill asks once whether to install
|
|
249
|
+
* the payload's copy) and `unparseable` (broken JSON on either side; the
|
|
250
|
+
* skill asks rather than merging blind).
|
|
251
|
+
*/
|
|
252
|
+
function configEntry(absRoot, absPayload, rel) {
|
|
253
|
+
let payloadJson;
|
|
254
|
+
try {
|
|
255
|
+
payloadJson = JSON.parse(readFileSync(join(absPayload, rel), 'utf8'));
|
|
256
|
+
} catch {
|
|
257
|
+
return { path: rel, unparseable: true };
|
|
258
|
+
}
|
|
259
|
+
if (!isPlainObject(payloadJson)) return { path: rel, unparseable: true };
|
|
260
|
+
const installed = join(absRoot, rel);
|
|
261
|
+
if (!existsSync(installed)) return { path: rel, notInstalled: true };
|
|
262
|
+
let workspaceJson;
|
|
263
|
+
try {
|
|
264
|
+
workspaceJson = JSON.parse(readFileSync(installed, 'utf8'));
|
|
265
|
+
} catch {
|
|
266
|
+
return { path: rel, unparseable: true };
|
|
267
|
+
}
|
|
268
|
+
if (!isPlainObject(workspaceJson)) return { path: rel, unparseable: true };
|
|
269
|
+
return { path: rel, ...configKeyDiff(payloadJson, workspaceJson) };
|
|
270
|
+
}
|
|
271
|
+
|
|
118
272
|
function isClassified(payloadRelPath) {
|
|
119
273
|
const first = payloadRelPath.split('/')[0];
|
|
120
274
|
return VERBATIM_ROOTS.includes(first);
|
|
@@ -196,7 +350,72 @@ function isOwnedByWorkspace(rel, localFiles) {
|
|
|
196
350
|
return localFiles.some((pattern) => globMatches(pattern, claudeRel));
|
|
197
351
|
}
|
|
198
352
|
|
|
199
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Which baseline the classification runs against. An explicit --baseline
|
|
355
|
+
* wins; otherwise the workspace's own <root>/.claude/.template-baseline.json
|
|
356
|
+
* is tried first, then the payload's .template-baseline.reconstructed.json
|
|
357
|
+
* (staged by --upgrade for workspaces that predate baselines). The fallback
|
|
358
|
+
* matters in the worktree flow: <root> is the task worktree, which cannot
|
|
359
|
+
* see launcher-only files, while the payload travels there by absolute path.
|
|
360
|
+
* A file that exists but does not parse counts as absent — a corrupt
|
|
361
|
+
* baseline must not block the reconstructed one (gh:186).
|
|
362
|
+
*/
|
|
363
|
+
export function resolveBaseline({ root, payload, baseline = null }) {
|
|
364
|
+
const candidates = baseline !== null
|
|
365
|
+
? [{ path: resolve(baseline), label: baseline }]
|
|
366
|
+
: [
|
|
367
|
+
{ path: join(resolve(root), BASELINE_PATH), label: BASELINE_PATH },
|
|
368
|
+
{ path: join(resolve(payload), RECONSTRUCTED_BASELINE_NAME), label: `.workspace-update/${RECONSTRUCTED_BASELINE_NAME}` },
|
|
369
|
+
];
|
|
370
|
+
for (const candidate of candidates) {
|
|
371
|
+
const parsed = readBaselineFile(candidate.path);
|
|
372
|
+
if (parsed !== null) return { baseline: parsed, source: candidate.label };
|
|
373
|
+
}
|
|
374
|
+
return { baseline: null, source: null };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* workspace.json keys whose absence carried a default in the version being
|
|
379
|
+
* upgraded FROM but not in the payload's. The canonical budget existed as an
|
|
380
|
+
* implicit default only between v0.15.0-beta.1 (gh:97, which introduced it:
|
|
381
|
+
* absent meant a 40960-byte budget) and v0.19.0-beta.0 (gh:164, which made
|
|
382
|
+
* it opt-in: absent means off since). Before v0.15 there was no budget at
|
|
383
|
+
* all, so a workspace upgrading from there also has none to preserve —
|
|
384
|
+
* reporting the key would turn trimming ON. Only an upgrade from inside
|
|
385
|
+
* that window into a workspace.json that never wrote the key reports it,
|
|
386
|
+
* and the skill writes the explicit value and says so (gh:190).
|
|
387
|
+
*/
|
|
388
|
+
const CANONICAL_BUDGET_INTRODUCED = '0.15.0-beta.1';
|
|
389
|
+
const CANONICAL_BUDGET_OPT_IN = '0.19.0-beta.0';
|
|
390
|
+
const CANONICAL_BUDGET_DEFAULT = 40960;
|
|
391
|
+
|
|
392
|
+
function implicitDefaults(absRoot, absPayload) {
|
|
393
|
+
let manifest;
|
|
394
|
+
try {
|
|
395
|
+
manifest = JSON.parse(readFileSync(join(absPayload, '.manifest.json'), 'utf8'));
|
|
396
|
+
} catch {
|
|
397
|
+
return []; // no manifest — the payload path is wrong; nothing to infer
|
|
398
|
+
}
|
|
399
|
+
const { fromVersion } = manifest;
|
|
400
|
+
if (typeof fromVersion !== 'string' || fromVersion === 'unknown') return [];
|
|
401
|
+
if (compareVersions(fromVersion, CANONICAL_BUDGET_INTRODUCED) < 0) return [];
|
|
402
|
+
if (compareVersions(fromVersion, CANONICAL_BUDGET_OPT_IN) >= 0) return [];
|
|
403
|
+
let config;
|
|
404
|
+
try {
|
|
405
|
+
config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
|
|
406
|
+
} catch {
|
|
407
|
+
return []; // no workspace.json to preserve a default in
|
|
408
|
+
}
|
|
409
|
+
const ws = config?.workspace && typeof config.workspace === 'object' ? config.workspace : null;
|
|
410
|
+
if (!ws || Object.prototype.hasOwnProperty.call(ws, 'canonicalBudgetBytes')) return [];
|
|
411
|
+
return [{
|
|
412
|
+
key: 'canonicalBudgetBytes',
|
|
413
|
+
value: CANONICAL_BUDGET_DEFAULT,
|
|
414
|
+
reason: `absent meant a ${CANONICAL_BUDGET_DEFAULT}-byte canonical budget before v0.19 and means off since — write the value explicitly or trimming silently stops`,
|
|
415
|
+
}];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
|
|
200
419
|
const absRoot = resolve(root);
|
|
201
420
|
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
202
421
|
if (!existsSync(absPayload)) {
|
|
@@ -205,21 +424,31 @@ export function classifyUpdate({ root, payload }) {
|
|
|
205
424
|
|
|
206
425
|
const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
|
|
207
426
|
const payloadSet = new Set(payloadFiles);
|
|
208
|
-
const baseline =
|
|
427
|
+
const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
|
|
209
428
|
|
|
210
429
|
const result = {
|
|
211
430
|
new: [],
|
|
212
431
|
identical: [],
|
|
213
432
|
updated: [],
|
|
214
433
|
differs: [],
|
|
434
|
+
config: [],
|
|
215
435
|
localOnly: [],
|
|
216
436
|
deletedLocally: [],
|
|
217
437
|
activated: [],
|
|
218
438
|
removed: [],
|
|
219
439
|
staleTests: [],
|
|
440
|
+
implicitDefaults: [],
|
|
220
441
|
hasBaseline: baseline !== null,
|
|
442
|
+
baselineSource: source,
|
|
443
|
+
baselineReconstructed: baseline !== null && baseline.reconstructed === true,
|
|
221
444
|
};
|
|
222
445
|
for (const rel of payloadFiles) {
|
|
446
|
+
// Jointly-owned JSON configs never compare by content — the config
|
|
447
|
+
// list carries a key-level diff for the skill to merge instead.
|
|
448
|
+
if (CONFIG_PATHS.has(rel)) {
|
|
449
|
+
result.config.push(configEntry(absRoot, absPayload, rel));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
223
452
|
// A .skip rule whose active counterpart is installed was deliberately
|
|
224
453
|
// activated by this workspace: report it as activated, not new.
|
|
225
454
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
|
|
@@ -270,6 +499,9 @@ export function classifyUpdate({ root, payload }) {
|
|
|
270
499
|
const gitignored = gitIgnoredPaths(absRoot, installedFiles);
|
|
271
500
|
for (const rel of installedFiles) {
|
|
272
501
|
if (skipSet.has(rel)) continue;
|
|
502
|
+
// A config file the payload dropped stays with the workspace: it holds
|
|
503
|
+
// user content the template never deletes.
|
|
504
|
+
if (CONFIG_PATHS.has(rel)) continue;
|
|
273
505
|
// An active rule whose .skip twin is in the payload is an activated rule,
|
|
274
506
|
// not a removed one.
|
|
275
507
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
|
|
@@ -281,11 +513,71 @@ export function classifyUpdate({ root, payload }) {
|
|
|
281
513
|
continue;
|
|
282
514
|
}
|
|
283
515
|
if (isOwnedByWorkspace(rel, localFiles)) continue;
|
|
284
|
-
|
|
516
|
+
// No baseline record means the template never shipped the file here —
|
|
517
|
+
// the workspace's own, not a template removal. Marked so the skill
|
|
518
|
+
// offers a workspace.localFiles entry instead of deletion; only a real
|
|
519
|
+
// baseline can prove the negative. An activated optional rule is the
|
|
520
|
+
// exception: the baseline records its .skip twin, which proves the
|
|
521
|
+
// template shipped it, so its removal stays plain (gh:190).
|
|
522
|
+
const templateShipped = typeof baseline?.files[rel] === 'string'
|
|
523
|
+
|| (rel.startsWith('.claude/rules/') && rel.endsWith('.md')
|
|
524
|
+
&& typeof baseline?.files[`${rel}.skip`] === 'string');
|
|
525
|
+
if (baseline && !templateShipped) {
|
|
526
|
+
result.removed.push({ file: rel, userOwned: true });
|
|
527
|
+
} else {
|
|
528
|
+
result.removed.push(rel);
|
|
529
|
+
}
|
|
285
530
|
}
|
|
531
|
+
linkRemovedHooks(result, absRoot);
|
|
532
|
+
result.implicitDefaults = implicitDefaults(absRoot, absPayload);
|
|
286
533
|
return result;
|
|
287
534
|
}
|
|
288
535
|
|
|
536
|
+
/**
|
|
537
|
+
* A removed hook that a workspace-only settings.json entry still registers
|
|
538
|
+
* must not be deleted while its registration stays: mark the removal with
|
|
539
|
+
* `referencedBy` — the config-diff paths (`settings.json hooks.{Event}`) —
|
|
540
|
+
* so the skill removes the file and the settings entry together (gh:190).
|
|
541
|
+
* References are looked for where the config diff shows the workspace
|
|
542
|
+
* holding what the payload doesn't: `arrays[].workspaceOnly` elements and
|
|
543
|
+
* `workspaceOnly` keys (whose value is read from its settings.json).
|
|
544
|
+
*/
|
|
545
|
+
function linkRemovedHooks(result, absRoot) {
|
|
546
|
+
const settings = result.config.find((c) => c.path === '.claude/settings.json');
|
|
547
|
+
if (!settings || settings.notInstalled || settings.unparseable) return;
|
|
548
|
+
const removedHooks = result.removed.filter(
|
|
549
|
+
(entry) => typeof entry === 'string' && entry.startsWith('.claude/hooks/'),
|
|
550
|
+
);
|
|
551
|
+
if (removedHooks.length === 0) return;
|
|
552
|
+
let wsHooks = null;
|
|
553
|
+
try {
|
|
554
|
+
const wsSettings = JSON.parse(readFileSync(join(absRoot, '.claude', 'settings.json'), 'utf8'));
|
|
555
|
+
if (isPlainObject(wsSettings?.hooks)) wsHooks = wsSettings.hooks;
|
|
556
|
+
} catch {
|
|
557
|
+
return; // the config entry already flagged it unparseable
|
|
558
|
+
}
|
|
559
|
+
for (let i = 0; i < result.removed.length; i++) {
|
|
560
|
+
const rel = result.removed[i];
|
|
561
|
+
if (typeof rel !== 'string' || !rel.startsWith('.claude/hooks/')) continue;
|
|
562
|
+
const referencedBy = [];
|
|
563
|
+
for (const arr of settings.arrays) {
|
|
564
|
+
if (arr.path.startsWith('hooks/') && arr.workspaceOnly.some((el) => JSON.stringify(el).includes(rel))) {
|
|
565
|
+
referencedBy.push(`settings.json ${arr.path.split('/').join('.')}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const path of settings.workspaceOnly) {
|
|
569
|
+
if (!path.startsWith('hooks/')) continue;
|
|
570
|
+
const event = wsHooks && wsHooks[path.split('/')[1]];
|
|
571
|
+
if (event !== undefined && JSON.stringify(event).includes(rel)) {
|
|
572
|
+
referencedBy.push(`settings.json ${path.split('/').join('.')}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (referencedBy.length > 0) {
|
|
576
|
+
result.removed[i] = { file: rel, referencedBy };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
289
581
|
// ---------- CLAUDE.md merge ----------
|
|
290
582
|
|
|
291
583
|
/**
|
|
@@ -417,7 +709,17 @@ function resolvePayload(args) {
|
|
|
417
709
|
}
|
|
418
710
|
|
|
419
711
|
function writeBaselineMode(args) {
|
|
420
|
-
const
|
|
712
|
+
const absPayload = resolvePayload(args);
|
|
713
|
+
// The previous baseline decides which declined updates keep their old
|
|
714
|
+
// entry — resolve it exactly as classification does, so the worktree flow
|
|
715
|
+
// (no baseline of its own yet) carries over from the payload's
|
|
716
|
+
// reconstructed one instead of starting from nothing.
|
|
717
|
+
const { baseline: previous } = resolveBaseline({
|
|
718
|
+
root: args.root,
|
|
719
|
+
payload: absPayload,
|
|
720
|
+
baseline: args.baseline,
|
|
721
|
+
});
|
|
722
|
+
const baseline = writeBaseline(args.root, absPayload, { previous });
|
|
421
723
|
process.stdout.write(JSON.stringify({
|
|
422
724
|
written: true,
|
|
423
725
|
path: BASELINE_PATH,
|
|
@@ -426,6 +728,25 @@ function writeBaselineMode(args) {
|
|
|
426
728
|
}, null, 2) + '\n');
|
|
427
729
|
}
|
|
428
730
|
|
|
731
|
+
/**
|
|
732
|
+
* `@{path}` include lines in a CLAUDE.md body whose target file does not
|
|
733
|
+
* exist at the workspace root. Machine-local targets (`local-only-*`
|
|
734
|
+
* basenames) are expected absent on machines that never wrote them — the
|
|
735
|
+
* same exemption the maintenance audit gives those imports — so they never
|
|
736
|
+
* report. The include-line shape mirrors context-footprint's resolveImports:
|
|
737
|
+
* a line whose trimmed content is exactly `@` plus a path.
|
|
738
|
+
*/
|
|
739
|
+
function missingIncludes(absRoot, text) {
|
|
740
|
+
const missing = [];
|
|
741
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
742
|
+
const m = /^@(\S+)$/.exec(rawLine.trim());
|
|
743
|
+
if (!m) continue;
|
|
744
|
+
if (m[1].split('/').pop().startsWith('local-only-')) continue;
|
|
745
|
+
if (!existsSync(resolve(absRoot, m[1]))) missing.push(m[1]);
|
|
746
|
+
}
|
|
747
|
+
return missing;
|
|
748
|
+
}
|
|
749
|
+
|
|
429
750
|
function mergeClaudeMdMode(args) {
|
|
430
751
|
const absRoot = resolve(args.root);
|
|
431
752
|
const absPayload = resolvePayload(args);
|
|
@@ -443,7 +764,14 @@ function mergeClaudeMdMode(args) {
|
|
|
443
764
|
const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
|
|
444
765
|
const claudeMdPath = join(absRoot, 'CLAUDE.md');
|
|
445
766
|
const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
|
|
446
|
-
|
|
767
|
+
const claudeMd = mergeClaudeMd(current, next);
|
|
768
|
+
// The gained-@include check is deterministic, not something to eyeball in
|
|
769
|
+
// the diff: every include line whose target is absent here is reported so
|
|
770
|
+
// the skill asks (stub or omit) instead of writing it silently (gh:190).
|
|
771
|
+
process.stdout.write(JSON.stringify({
|
|
772
|
+
claudeMd,
|
|
773
|
+
missingIncludes: missingIncludes(absRoot, claudeMd),
|
|
774
|
+
}, null, 2) + '\n');
|
|
447
775
|
}
|
|
448
776
|
|
|
449
777
|
function main() {
|
|
@@ -453,7 +781,7 @@ function main() {
|
|
|
453
781
|
} else if (args.mergeClaudeMd) {
|
|
454
782
|
mergeClaudeMdMode(args);
|
|
455
783
|
} else {
|
|
456
|
-
const result = classifyUpdate({ root: args.root, payload: args.payload });
|
|
784
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload, baseline: args.baseline });
|
|
457
785
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
458
786
|
}
|
|
459
787
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// Teardown order is MANDATORY:
|
|
5
5
|
// 1. Remove each project worktree from its project repo
|
|
6
6
|
// 2. Remove the workspace worktree from the workspace repo
|
|
7
|
-
// 3. Prune each project repo (belt-and-suspenders
|
|
7
|
+
// 3. Prune each project repo (belt-and-suspenders — and only when every
|
|
8
|
+
// prunable record in the repo belongs to this session; a repo also
|
|
9
|
+
// holding an unrelated external worktree is never pruned, so its
|
|
10
|
+
// record survives gh:187)
|
|
8
11
|
// 4. Delete all local branches
|
|
9
12
|
// 5. Remove the whole work-sessions/{name}/ folder
|
|
10
13
|
//
|
|
@@ -83,6 +86,25 @@ function isRepoSegment(name) {
|
|
|
83
86
|
return !/^\.+$/.test(segs[0]);
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
// `worktree list --porcelain` stanzas reduced to what prune scoping needs:
|
|
90
|
+
// each stanza's worktree path and whether git marked it prunable. Paths
|
|
91
|
+
// are kept exactly as git printed them (realOf is applied by callers, so
|
|
92
|
+
// a record whose directory is gone survives the comparison).
|
|
93
|
+
function worktreeStanzas(porcelain) {
|
|
94
|
+
const stanzas = [];
|
|
95
|
+
let cur = null;
|
|
96
|
+
for (const line of String(porcelain).split(/\r?\n/)) {
|
|
97
|
+
if (line.startsWith('worktree ')) {
|
|
98
|
+
if (cur) stanzas.push(cur);
|
|
99
|
+
cur = { path: line.slice(9), prunable: false };
|
|
100
|
+
} else if (cur && line.startsWith('prunable')) {
|
|
101
|
+
cur.prunable = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (cur) stanzas.push(cur);
|
|
105
|
+
return stanzas;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
const args = process.argv.slice(2);
|
|
87
109
|
const getArg = (name) => {
|
|
88
110
|
const idx = args.indexOf(`--${name}`);
|
|
@@ -335,9 +357,42 @@ if (existsSync(wsWorktree)) {
|
|
|
335
357
|
}
|
|
336
358
|
|
|
337
359
|
// === Step 3: Prune each project repo to mop up orphans ===
|
|
360
|
+
//
|
|
361
|
+
// `git worktree prune` has no path filter: one invocation drops EVERY
|
|
362
|
+
// prunable record the repo holds, including records of worktrees that
|
|
363
|
+
// have nothing to do with this workspace — a scratch checkout in tmp, a
|
|
364
|
+
// directory on another drive (gh:187). Those belong to no session, so
|
|
365
|
+
// prune runs only when every prunable record in the repo sits under this
|
|
366
|
+
// session's folder; otherwise it is skipped and the records are named in
|
|
367
|
+
// the output, left registered for whoever owns them.
|
|
368
|
+
const sessionFolderReal = realOf(sessionFolder);
|
|
369
|
+
const sessionFolderAbs = resolve(sessionFolder);
|
|
370
|
+
const underSessionFolder = (recordedPath) => {
|
|
371
|
+
const normalized = realOf(recordedPath);
|
|
372
|
+
return [sessionFolderReal, sessionFolderAbs].some(
|
|
373
|
+
(base) => normalized === base || normalized.startsWith(base + sep)
|
|
374
|
+
|| recordedPath === base || recordedPath.startsWith(base + sep),
|
|
375
|
+
);
|
|
376
|
+
};
|
|
338
377
|
for (const repo of repos) {
|
|
339
378
|
const repoDir = join(reposDir, repo);
|
|
340
379
|
if (!existsSync(repoDir)) continue;
|
|
380
|
+
const listRes = git(repoDir, ['worktree', 'list', '--porcelain']);
|
|
381
|
+
if (!listRes.ok) {
|
|
382
|
+
errors.push(`Could not list worktrees in ${repo}: ${listRes.err || listRes.out}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const foreign = worktreeStanzas(listRes.out)
|
|
386
|
+
.filter((s) => s.prunable && !underSessionFolder(s.path))
|
|
387
|
+
.map((s) => s.path);
|
|
388
|
+
if (foreign.length > 0) {
|
|
389
|
+
skipped.push({
|
|
390
|
+
step: 'prune',
|
|
391
|
+
repo,
|
|
392
|
+
reason: `prune skipped: ${repo} has prunable worktree records outside this session (${foreign.join(', ')}) — a blanket prune would drop them; they were left registered for their owner`,
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
341
396
|
const res = git(repoDir, ['worktree', 'prune']);
|
|
342
397
|
if (!res.ok) {
|
|
343
398
|
// Prune is a safety net, but if it fails on a repo we touched, surface
|
|
@@ -407,8 +462,14 @@ for (const repo of repos) {
|
|
|
407
462
|
continue;
|
|
408
463
|
}
|
|
409
464
|
const wtList = listRes.out;
|
|
410
|
-
|
|
411
|
-
|
|
465
|
+
// Only this session's own prunable records are a leftover gh:119 orphan;
|
|
466
|
+
// a prunable record elsewhere in the repo is someone else's worktree and
|
|
467
|
+
// stays exactly as found (step 3 refused to prune it for that reason).
|
|
468
|
+
const leftover = worktreeStanzas(wtList)
|
|
469
|
+
.filter((s) => s.prunable && underSessionFolder(s.path))
|
|
470
|
+
.map((s) => s.path);
|
|
471
|
+
if (leftover.length > 0) {
|
|
472
|
+
errors.push(`Prunable worktree record(s) remain in ${repo} after cleanup (${leftover.join(', ')}) — the gh:119 symptom; inspect them, then run git -C ${repoDir} worktree prune yourself`);
|
|
412
473
|
}
|
|
413
474
|
if (wtList.includes(wsPath)) {
|
|
414
475
|
errors.push(`${repo} still has a worktree record referencing the session path`);
|