@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.
@@ -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 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
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 .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.
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. The payload's templates (*.tmpl, which
63
- // install with {{project-name}} substitution), _gitignore (merged line-by-line
64
- // into the workspace's .gitignore), and .manifest.json (payload metadata) are
65
- // handled by their own steps in /workspace-update and are excluded here.
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 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.
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 { BASELINE_PATH, hashBytes, readBaseline, writeBaseline } from './template-baseline.mjs';
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
- export function classifyUpdate({ root, payload }) {
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 = readBaseline(absRoot);
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
- result.removed.push(rel);
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 baseline = writeBaseline(args.root, resolvePayload(args));
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
- process.stdout.write(mergeClaudeMd(current, next));
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
- if (wtList.includes('prunable')) {
411
- errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
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`);