@ulysses-ai/create-workspace 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.23.0-beta.0",
3
+ "version": "0.23.1-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -67,11 +67,25 @@
67
67
  // (machine-local), paths under .claude/worktrees/, and entries
68
68
  // of workspace.json → workspace.localFiles (array of
69
69
  // .claude/-relative paths or globs for files this workspace
70
- // 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.
71
77
  // staleTests — *.test.mjs files under .claude/ with no payload counterpart.
72
78
  // The npm tarball ships no tests, so these came from a dev
73
79
  // checkout and are never updated by /workspace-update; the
74
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).
75
89
  //
76
90
  // Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
77
91
  // (which file it came from) and `baselineReconstructed`. The default
@@ -103,10 +117,15 @@
103
117
  // present as `updated` again next time; see
104
118
  // template-baseline.mjs. Throws rather than writing an
105
119
  // empty baseline.
106
- // --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
107
- // merged in: template lines updated, the workspace's own
108
- // lines (custom skill entries, sections) kept. The skill
109
- // shows the diff against the current file before writing.
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).
110
129
 
111
130
  import {
112
131
  existsSync,
@@ -118,6 +137,7 @@ import {
118
137
  import { basename, join, resolve } from 'node:path';
119
138
  import { fileURLToPath } from 'node:url';
120
139
  import { gitIgnoredPaths } from './build-workspace-context.mjs';
140
+ import { compareVersions } from '../lib/registry-check.mjs';
121
141
  import {
122
142
  BASELINE_PATH,
123
143
  RECONSTRUCTED_BASELINE_NAME,
@@ -354,6 +374,47 @@ export function resolveBaseline({ root, payload, baseline = null }) {
354
374
  return { baseline: null, source: null };
355
375
  }
356
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
+
357
418
  export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
358
419
  const absRoot = resolve(root);
359
420
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
@@ -376,6 +437,7 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
376
437
  activated: [],
377
438
  removed: [],
378
439
  staleTests: [],
440
+ implicitDefaults: [],
379
441
  hasBaseline: baseline !== null,
380
442
  baselineSource: source,
381
443
  baselineReconstructed: baseline !== null && baseline.reconstructed === true,
@@ -451,11 +513,71 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
451
513
  continue;
452
514
  }
453
515
  if (isOwnedByWorkspace(rel, localFiles)) continue;
454
- 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
+ }
455
530
  }
531
+ linkRemovedHooks(result, absRoot);
532
+ result.implicitDefaults = implicitDefaults(absRoot, absPayload);
456
533
  return result;
457
534
  }
458
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
+
459
581
  // ---------- CLAUDE.md merge ----------
460
582
 
461
583
  /**
@@ -606,6 +728,25 @@ function writeBaselineMode(args) {
606
728
  }, null, 2) + '\n');
607
729
  }
608
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
+
609
750
  function mergeClaudeMdMode(args) {
610
751
  const absRoot = resolve(args.root);
611
752
  const absPayload = resolvePayload(args);
@@ -623,7 +764,14 @@ function mergeClaudeMdMode(args) {
623
764
  const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
624
765
  const claudeMdPath = join(absRoot, 'CLAUDE.md');
625
766
  const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
626
- 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');
627
775
  }
628
776
 
629
777
  function main() {
@@ -25,7 +25,11 @@
25
25
  // of the git common dir), while the worktree's own
26
26
  // branch is only named in an info line and its dirty
27
27
  // tracked tree is skipped as info: in-flight work, not
28
- // drift (gh:183)
28
+ // drift (gh:183). The launcher's
29
+ // .claude/skills/workspace-update/ modification an
30
+ // --upgrade leaves when its content equals the staged
31
+ // payload's is the expected bootstrap, reported as
32
+ // info, never a dirty-tree warning (gh:190)
29
33
  // 5. auto-files — workspace-context catalogs current (the same
30
34
  // semantics as build-workspace-context.mjs --check)
31
35
  // 6. budget — always-loaded context within
@@ -82,6 +86,7 @@ import {
82
86
  readIgnorePrefixes,
83
87
  isIgnored,
84
88
  } from './build-workspace-context.mjs';
89
+ import { hashBytes } from './template-baseline.mjs';
85
90
  import { refreshIfStale } from '../lib/freshness.mjs';
86
91
  import { parseSessionContent } from '../lib/session-frontmatter.mjs';
87
92
 
@@ -157,7 +162,10 @@ export async function runAudit({
157
162
  // array — a structural delimiter, never a byte that could appear in the
158
163
  // values (NUL separators made git treat this file as binary).
159
164
  const seenFindings = new Set();
160
- const add = (section, severity, file, message) => {
165
+ // opts.noFromUpdate: expected-absent findings (a machine-local import
166
+ // missing inside a task worktree) stay ambient info even when their file
167
+ // is on the --changed list — the update did not cause them (gh:190).
168
+ const add = (section, severity, file, message, opts = {}) => {
161
169
  const key = JSON.stringify([section, severity, file, message]);
162
170
  if (seenFindings.has(key)) return;
163
171
  seenFindings.add(key);
@@ -166,7 +174,7 @@ export async function runAudit({
166
174
  severity,
167
175
  file,
168
176
  message,
169
- ...(changedSet.has(file) ? { fromUpdate: true } : {}),
177
+ ...(changedSet.has(file) && !opts.noFromUpdate ? { fromUpdate: true } : {}),
170
178
  });
171
179
  };
172
180
 
@@ -323,7 +331,10 @@ export async function runAudit({
323
331
  const posix = toPosix(spec);
324
332
  const base = posix.split('/').pop();
325
333
  if (base.startsWith('local-only-')) {
326
- add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`);
334
+ // Machine-local files never materialize inside a task worktree (the
335
+ // update flow audits from one), so this is ambient, never something
336
+ // the update caused (gh:190).
337
+ add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`, { noFromUpdate: true });
327
338
  } else if (posix === 'CODEBASE.md') {
328
339
  add('cross-reference', 'info', 'CLAUDE.md', '@CODEBASE.md is absent — optional stub, /workspace-init generates it on request');
329
340
  } else if (isAutoFileRel(posix, wcDir)) {
@@ -379,6 +390,9 @@ export async function runAudit({
379
390
  return relToWC.split('/').includes('archive');
380
391
  };
381
392
 
393
+ // Resolved lifecycles are closed out, not defects — however many there
394
+ // are, they surface as ONE info line, not one per file (gh:190).
395
+ const resolvedFiles = [];
382
396
  for (let i = 0; i < files.length; i++) {
383
397
  const rel = rels[i];
384
398
  if (ignored.has(rel) || isHistorical(rel)) continue;
@@ -435,12 +449,17 @@ export async function runAudit({
435
449
  }
436
450
  }
437
451
  if (f.lifecycle === 'resolved') {
438
- add('frontmatter', 'info', rel, 'lifecycle resolved — confirm /complete-work has processed it');
452
+ resolvedFiles.push(rel);
439
453
  }
440
454
  if ('confidence' in f && !['high', 'medium', 'low'].includes(f.confidence)) {
441
455
  add('frontmatter', 'warning', rel, `confidence '${f.confidence}' is not one of high, medium, low`);
442
456
  }
443
457
  }
458
+ if (resolvedFiles.length > 0) {
459
+ const names = resolvedFiles.slice(0, 3).map((rel) => rel.split('/').pop());
460
+ add('frontmatter', 'info', wcDir,
461
+ `${resolvedFiles.length} lifecycle resolved file(s) — confirm /complete-work has processed them (${names.join(', ')}${resolvedFiles.length > 3 ? ', …' : ''})`);
462
+ }
444
463
  })();
445
464
 
446
465
  // ---------- 4. git state ----------
@@ -463,11 +482,31 @@ export async function runAudit({
463
482
  add('git', 'warning', '.',
464
483
  `launcher is on branch '${gitInfo.launcherBranch}' — it stays on its default branch ('${gitInfo.defaultBranch}')`);
465
484
  }
485
+ // --upgrade replaces the launcher's workspace-update skill before the
486
+ // payload is applied, and the merged PR delivers the same content back:
487
+ // a modification that equals the staged payload's copy is the expected
488
+ // bootstrap, not drift — info, and out of the dirty warning (gh:190).
489
+ const porcelainPath = (line) => line.slice(line.indexOf(' ') + 1).split(' -> ')[0];
466
490
  const launcherDirty = gitInfo.launcherPorcelain.filter((l) => !l.startsWith('??'));
467
- if (launcherDirty.length > 0) {
468
- const paths = launcherDirty.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
491
+ const bootstrap = launcherDirty.filter((l) => {
492
+ const p = porcelainPath(l);
493
+ if (!p.startsWith('.claude/skills/workspace-update/')) return false;
494
+ try {
495
+ return hashBytes(readFileSync(join(gitInfo.launcherRoot, p)))
496
+ === hashBytes(readFileSync(join(gitInfo.launcherRoot, '.workspace-update', p)));
497
+ } catch {
498
+ return false; // no staged payload copy to compare against
499
+ }
500
+ });
501
+ if (bootstrap.length > 0) {
502
+ add('git', 'info', '.',
503
+ "launcher's .claude/skills/workspace-update/ replaced by --upgrade (matches the staged payload) — expected until the update merges");
504
+ }
505
+ const drift = launcherDirty.filter((l) => !bootstrap.includes(l));
506
+ if (drift.length > 0) {
507
+ const paths = drift.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
469
508
  add('git', 'warning', '.',
470
- `launcher has ${launcherDirty.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${launcherDirty.length > 5 ? ', …' : ''}`);
509
+ `launcher has ${drift.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${drift.length > 5 ? ', …' : ''}`);
471
510
  }
472
511
  add('git', 'info', '.',
473
512
  'uncommitted-changes check skipped — the worktree is expected to carry in-flight changes');
@@ -45,7 +45,11 @@
45
45
  // folder is renamed into {sessions}/.archived/ and git's
46
46
  // worktree links are repaired to follow it. Refuses when a
47
47
  // worktree holds uncommitted changes (an edited session.md
48
- // counts) unless --allow-uncommitted says leave them be.
48
+ // counts) unless --allow-uncommitted says leave them be,
49
+ // and when a worktree tip holds commits no remote backs
50
+ // (they exist only on this machine) unless --allow-unbacked
51
+ // records that the operator saw the counts and declined
52
+ // the backup.
49
53
  // --enable-task-model
50
54
  // flip workspace.sessionModel to "task" (accepts a task
51
55
  // worktree root — the one mode allowed off the launcher)
@@ -585,8 +589,9 @@ function readTracker(wsDir) {
585
589
  : rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
586
590
  // A chat session with ended: null may still be open — session-start
587
591
  // records one per chat and session-end fills ended in. Counting them
588
- // is how the inventory flags "a chat may be working in this session
589
- // right now" before anyone proposes archiving it.
592
+ // is the raw material for the chat-open flag; whether the count means
593
+ // "a chat may be working here right now" is decided against the
594
+ // session's recency in inspectSession, not here.
590
595
  const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
591
596
  const openChats = chats.filter((c) => c && typeof c === 'object' && (c.ended == null || c.ended === '')).length;
592
597
  return {
@@ -873,6 +878,14 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
873
878
  // mid-completion), while an unparseable file is tracker damage the
874
879
  // operator should hear about. Neither ever crashes the inspection.
875
880
  const trackerStripped = !existsSync(join(wsDir, 'session.md'));
881
+ // The tracker file's own mtime: a chat resuming the session rewrites
882
+ // session.md (the session-start hook registers it in chatSessions), so a
883
+ // fresh mtime is live-chat evidence even when no commit or reflog entry
884
+ // followed. It feeds only the chat-open question below, never
885
+ // lastActivity — an uncommitted tracker edit is bookkeeping, not work
886
+ // (same line dirtyContentMtimeMs draws).
887
+ let trackerMtimeMs = null;
888
+ try { trackerMtimeMs = statSync(join(wsDir, 'session.md')).mtimeMs; } catch { /* absent or unreadable — no signal */ }
876
889
  const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
877
890
  const worktrees = rawWorktrees
878
891
  .filter((w) => w.kind !== 'foreign')
@@ -905,11 +918,36 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
905
918
 
906
919
  const { proposal, reasons } = classify({ name, worktrees, lastActivity, trackerStripped }, activeDays, now);
907
920
  const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
921
+ // A chat with no recorded end only means "may still be open" while
922
+ // something in the session is also recent: session-end misses often
923
+ // enough (a crashed chat, a skipped hook) that an entry sitting on a
924
+ // session idle for months is a stale record, not a live chat. Recency is
925
+ // the newest of the session's own activity signals (lastActivity —
926
+ // own-branch commits, reflog, dirty content, the tracker's updated
927
+ // field) and the tracker file's mtime. When no signal can establish
928
+ // either answer, the cautious reading stands.
929
+ let chatOpen = false;
930
+ let chatIdleDays = null;
908
931
  if (tracker && tracker.openChats > 0) {
909
- warnings.push({
910
- kind: 'chat-open',
911
- message: `${tracker.openChats} chat session(s) recorded with no end time — a chat may still be working in this session; confirm with the operator before archiving it`,
912
- });
932
+ const newestChatSignal = Math.max(
933
+ Number.isFinite(lastMs) ? lastMs : -Infinity,
934
+ trackerMtimeMs ?? -Infinity,
935
+ );
936
+ if (!Number.isFinite(newestChatSignal) || now - newestChatSignal <= activeDays * DAY_MS) {
937
+ chatOpen = true;
938
+ warnings.push({
939
+ kind: 'chat-open',
940
+ message: `${tracker.openChats} chat session(s) recorded with no end time — a chat may still be working in this session; confirm with the operator before archiving it`,
941
+ });
942
+ } else {
943
+ chatIdleDays = Math.floor((now - newestChatSignal) / DAY_MS);
944
+ warnings.push({
945
+ kind: 'chat-open-idle',
946
+ info: true,
947
+ idleDays: chatIdleDays,
948
+ message: `${tracker.openChats} chat session(s) recorded with no end time, but nothing in the session has moved for ${chatIdleDays} day(s) — the end was most likely never recorded; treat the chat as closed unless the operator knows otherwise`,
949
+ });
950
+ }
913
951
  }
914
952
  if (!trackerStripped && !tracker) {
915
953
  warnings.push({
@@ -922,7 +960,8 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
922
960
  kind: 'session',
923
961
  status: tracker?.status ?? null,
924
962
  workItem: tracker?.workItem ?? null,
925
- chatOpen: tracker ? tracker.openChats > 0 : false,
963
+ chatOpen,
964
+ ...(chatIdleDays != null ? { chatIdleDays } : {}),
926
965
  lastActivity,
927
966
  proposal,
928
967
  reasons,
@@ -1753,12 +1792,17 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1753
1792
  * find; a worktree holds uncommitted or untracked changes — an edited
1754
1793
  * session.md counts (unless allowUncommitted: they would ride along
1755
1794
  * fine, but they deserve a decision: commit them to the session branch,
1756
- * or explicitly accept archiving them mid-edit); or the archive
1757
- * directory is a symlink or resolves outside the workspace. If anything
1758
- * fails after the rename, the folder is renamed back and repaired, and
1759
- * the result reports the verified state.
1795
+ * or explicitly accept archiving them mid-edit); or a worktree tip holds
1796
+ * commits no remote backs — they exist only on this machine, and the
1797
+ * archive is safe for them but the moment it is deleted they are gone
1798
+ * (unless allowUnbacked, the operator's recorded decline after seeing
1799
+ * the per-repo counts; the refusal names them, and a successful archive
1800
+ * that carried unbacked tips reports them in `unbacked`). Also refused
1801
+ * when the archive directory is a symlink or resolves outside the
1802
+ * workspace. If anything fails after the rename, the folder is renamed
1803
+ * back and repaired, and the result reports the verified state.
1760
1804
  */
1761
- function archiveSession(root, { session, allowUncommitted = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1805
+ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1762
1806
  const rootDir = resolveRoot(root);
1763
1807
  if (!isSessionSegment(session)) {
1764
1808
  throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
@@ -1824,6 +1868,30 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1824
1868
  }
1825
1869
  }
1826
1870
  }
1871
+ // Unpushed commits ride along safely too — but "safely" holds only as
1872
+ // long as the archive exists, and deleting it later is the operator's
1873
+ // own call. A tip whose commits no remote backs (the inventory's
1874
+ // `unbacked` evidence, recomputed here at archive time) exists only on
1875
+ // this machine, so the archive moves only once a remote holds every
1876
+ // such tip — the backup step's push mode --remote with an allow — or
1877
+ // after --allow-unbacked records that the operator saw these counts and
1878
+ // declined it. allowUnbacked still reports what rode along.
1879
+ const unbacked = [];
1880
+ for (const f of found) {
1881
+ const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
1882
+ const info = inspectWorktree(
1883
+ gitFn, rootDir,
1884
+ f.owner.repo === WORKSPACE_REPO ? 'workspace' : 'project',
1885
+ f.owner.repo, wtPath, null,
1886
+ );
1887
+ if (info.ahead > 0 && !info.backedBy) unbacked.push(info);
1888
+ }
1889
+ if (unbacked.length > 0 && !allowUnbacked) {
1890
+ reasons.push(
1891
+ ...unbacked.map((wt) => unbackedMessage(gitFn, rootDir, wt)),
1892
+ `${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (--backup --remote with the operator's allow pushes backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
1893
+ );
1894
+ }
1827
1895
  if (reasons.length > 0) return { refused: true, reasons };
1828
1896
 
1829
1897
  const archiveDir = join(sessionsDir, '.archived');
@@ -1867,6 +1935,9 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1867
1935
  from: relative(rootDir, folder),
1868
1936
  to: relative(rootDir, dest),
1869
1937
  worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
1938
+ // What the operator accepted riding along unbacked — the same
1939
+ // repos and counts the refusal would have named.
1940
+ ...(unbacked.length > 0 ? { unbacked: unbacked.map((wt) => ({ repo: wt.repo, branch: wt.branch, commits: wt.ahead })) } : {}),
1870
1941
  warnings: scan.outwardLinks.map((l) => `relative symlink ${relative(rootDir, join(dest, relative(folder, l)))} pointed outside the session and no longer resolves after the move — it was kept as-is`),
1871
1942
  };
1872
1943
  }
@@ -1927,7 +1998,7 @@ function renderTable(result) {
1927
1998
  : s.kind === 'foreign' ? 'foreign entry' : s.proposal;
1928
1999
  const detail = s.kind === 'broken' || s.kind === 'foreign'
1929
2000
  ? ''
1930
- : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : ''})`;
2001
+ : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : s.chatIdleDays != null ? `, chat: no end recorded (idle ${s.chatIdleDays}d)` : ''})`;
1931
2002
  lines.push(`${s.name} ${header}${detail}`);
1932
2003
  for (const w of s.worktrees || []) {
1933
2004
  const remotes = Object.entries(w.remotes)
@@ -1952,7 +2023,9 @@ function renderTable(result) {
1952
2023
  }
1953
2024
  }
1954
2025
  for (const r of s.reasons || []) lines.push(` · ${r}`);
1955
- for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
2026
+ // `!` marks something to act on; an entry carrying info: true is
2027
+ // context (a stale record explained, not a live risk).
2028
+ for (const w of s.warnings || []) lines.push(` ${w.info ? 'i' : '!'} ${w.message}`);
1956
2029
  }
1957
2030
  if (Array.isArray(result.externalWorktrees) && result.externalWorktrees.length > 0) {
1958
2031
  lines.push('');
@@ -1976,6 +2049,7 @@ const BOOL_FLAGS = new Map([
1976
2049
  ['--remote', 'remote'],
1977
2050
  ['--remote-allow-all', 'remoteAllowAll'],
1978
2051
  ['--allow-uncommitted', 'allowUncommitted'],
2052
+ ['--allow-unbacked', 'allowUnbacked'],
1979
2053
  ['--fetch', 'fetch'],
1980
2054
  ]);
1981
2055
 
@@ -1990,6 +2064,7 @@ function parseArgs(argv) {
1990
2064
  remote: false,
1991
2065
  remoteAllowAll: false,
1992
2066
  allowUncommitted: false,
2067
+ allowUnbacked: false,
1993
2068
  fetch: false,
1994
2069
  };
1995
2070
  const rest = argv.slice(2);
@@ -2045,6 +2120,9 @@ function parseArgs(argv) {
2045
2120
  if (args.allowUncommitted && args.mode !== 'archive') {
2046
2121
  throw new Error('--allow-uncommitted is only valid with --archive');
2047
2122
  }
2123
+ if (args.allowUnbacked && args.mode !== 'archive') {
2124
+ throw new Error('--allow-unbacked is only valid with --archive');
2125
+ }
2048
2126
  if (args.dryRun && args.mode !== 'backup') {
2049
2127
  throw new Error('--dry-run is only valid with --backup');
2050
2128
  }
@@ -2073,7 +2151,11 @@ function main() {
2073
2151
  dryRun: args.dryRun,
2074
2152
  });
2075
2153
  } else if (args.mode === 'archive') {
2076
- out = archiveSession(rootDir, { session: args.session, allowUncommitted: args.allowUncommitted });
2154
+ out = archiveSession(rootDir, {
2155
+ session: args.session,
2156
+ allowUncommitted: args.allowUncommitted,
2157
+ allowUnbacked: args.allowUnbacked,
2158
+ });
2077
2159
  } else {
2078
2160
  out = enableTaskModel(rootDir);
2079
2161
  }
@@ -22,29 +22,33 @@ Read-only. Present the stderr table plus each session's proposal with its reason
22
22
  Three more kinds of evidence change what you propose:
23
23
 
24
24
  - **Fetch age** — every worktree line ends `fetch:…` with the age of its repo's last fetch, and anything over a day draws a `stale-fetch` warning: the commits-ahead and content counts ride on tracking refs frozen at that fetch. Tell the operator to fetch first, or re-run with `--inventory --fetch`, which fetches each touched source clone before inspecting it. A fetch is read-only with respect to the workspace's own state — it moves no local branch and touches no worktree, only refs/remotes/* and the object store — and a repo with no origin or an unreachable one is recorded as skipped or failed, never fatal.
25
- - **Open chats** — `chat open?` on a session (with a `chat-open` warning) means its tracker records a chat session with no `ended:` — a chat may still be working in it. Confirm with the operator before proposing Archive.
25
+ - **Open chats** — `chat open?` on a session (with a `chat-open` warning) means its tracker records a chat session with no `ended:` AND the session shows activity within the active window (14 days by default) — a chat may still be working in it; confirm with the operator before proposing Archive. An open-chat entry on a session with nothing recent is a stale record, not a live chat — session-end misses often enough (a crashed chat, a skipped hook) — so it shows as `chat: no end recorded (idle {N}d)` with an informational `chat-open-idle` entry instead. Only the recent kind changes what Archive means.
26
26
  - **External worktrees** — a trailing section lists worktrees the workspace's repos register outside the workspace root (a scratch checkout in tmp, a directory elsewhere). They belong to no session: nothing in this migration prunes, moves, or removes them, and you never should either — including by hand, because `git worktree prune` has no path filter and would drop their records.
27
27
 
28
28
  ## 2. Decide per session, with the operator — one at a time
29
29
 
30
30
  For each session, lay out its evidence and ask the operator which way to go. Never infer the decision from the proposal. The options:
31
31
 
32
- - **Finish** (typical for MERGEABLE, and the default offer for READY_TO_COMPLETE — a session `/complete-work` stopped partway through: tracker already stripped, worktrees still live, branch often already pushed) — resume it with `/start-work` (its walk lists sessions by their tracker, so a stripped one will not appear — name it and re-create a minimal tracker from the inventory's branch/repos before continuing), then run `/complete-work`; its own merge confirmation applies there. But if the inventory shows a **diverged** remote for that session, say so *before* the operator chooses Finish: `/complete-work`'s plain push will be rejected, and pushing the rewritten history needs `--force-with-lease` — which you run only on the operator's explicit yes naming the branch. Never force silently.
32
+ - **Finish** (typical for MERGEABLE, and the default offer for READY_TO_COMPLETE — a session `/complete-work` stopped partway through: tracker already stripped, worktrees still live, branch often already pushed) — finish it from **this same chat**, the launcher chat. A new chat is not needed and would not work: `/complete-work` finds a session from the launcher only when this chat's id is registered in that session's `chatSessions`, and `/start-work`'s resume flow is what registers it. In this chat:
33
+ 1. Run `/start-work` and pick the session to resume it. (A READY_TO_COMPLETE session has no tracker, and `/start-work`'s walk lists sessions by their tracker, so it will not appear — name it and re-create a minimal tracker from the inventory's `branch`/`repos` first, then resume.)
34
+ 2. Run `/complete-work` — since v0.23 it detects sessions this chat is registered on straight from the launcher, asks which to finish, and proceeds with its own merge confirmation.
35
+
36
+ If the inventory shows a **diverged** remote for that session, say so *before* the operator chooses Finish: `/complete-work`'s plain push will be rejected, and pushing the rewritten history needs `--force-with-lease` — which you run only on the operator's explicit yes naming the branch. Never force silently.
33
37
  - **Archive** (typical for ABANDONED, an ORPHAN_SHELL that still holds files, or a MERGEABLE the operator gives up on) — take it out of the active lifecycle without destroying anything. When the inventory shows `chat open?`, confirm with the operator that the chat is really done before offering Archive — a live chat's uncommitted work would ride into the archive unseen. Three steps, in this order, each its own decision:
34
38
  1. **Clear uncommitted work.** `--archive` refuses when any of the session's worktrees has uncommitted or untracked changes — an edited `session.md` counts — and names them, because edits buried uncommitted in an archive are invisible to every later merge or PR. Offer the operator: commit them to the session branch first (`git -C {worktree} add -A`, then `git -C {worktree} commit -m "…"` — per dirty worktree), discard them explicitly (`git -C {worktree} restore …` / `git -C {worktree} clean …`), or, on an explicit yes, re-run with `--allow-uncommitted` to archive them mid-edit. Do this before the backup: the backup tags committed tips only, so committing first brings those edits under the backup, while anything archived with `--allow-uncommitted` is NOT in it.
35
39
  2. **Offer a backup.** Archiving keeps everything on this machine; a backup adds an off-machine copy of the session's commits, and it is what makes a later deletion safe. It covers the committed tips as they stand after step 1. Plain `--backup` creates `drain/{session}/…` tags locally and pushes nothing — for a repo whose only remote is one the operator does not own, that local tag IS the backup. Pushing is a separate, explicitly allowed step:
36
40
  ```bash
37
41
  node .claude/scripts/migrate-sessions.mjs --backup --session {name} --remote
38
42
  ```
39
- With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining is fine — the archive still keeps everything locally.
40
- 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}`. The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, or when it holds a submodule checkout (its link cannot be repaired) — surface the reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move).
43
+ With no allow flag this pushes nothing and exits non-zero, listing per repo the tag and the exact URL(s) it would push to. Approve against the push URL, not the fetch URL: a remote whose push URL differs from its fetch URL shows both, and the script refuses to push there on `--remote-allow-all` — only an explicit `--remote-allow {repo}={remote}` naming it proceeds. Walk the operator through every URL and ask per repo. **Never push backup tags to a remote the operator does not own — a third-party upstream, a read-only mirror, an unfamiliar push URL; pushing `drain/*` tags there publishes the session's commits somewhere foreign.** On yes for repos they do own, re-run adding `--remote-allow {repo}={remote}` per repo (`.` is the workspace repo; `--remote-allow-all` only when every listed URL is their own) and show what was pushed. Declining the push is fine — the archive still keeps everything locally — but the decline must be explicit: step 3's `--archive` refuses while any tip holds commits no remote backs, and proceeds only with `--allow-unbacked`, the operator's recorded no after seeing the counts.
44
+ 3. **Archive after an explicit yes naming the session:** `node .claude/scripts/migrate-sessions.mjs --archive --session {name}` (add `--allow-unbacked` only as the decline recorded in step 2). The whole session folder moves to `{sessions}/.archived/{name}--{timestamp}/` and git's worktree links are repaired to follow it — every commit, uncommitted edit, untracked or ignored file, and embedded repository comes along. If the move or the repair fails, the session is put back and the result says whether every link was verified. The session's branches stay checked out in the archived worktrees, so a new task cannot reuse those branch names until the archive is deleted. The archive refuses, touching nothing, when a directory in the folder cannot be read, when the folder holds a worktree of a repository outside this workspace, when it holds a submodule checkout (its link cannot be repaired), or when a worktree tip holds commits no remote backs — that refusal names each repo and its commit count, exactly the backup decision step 2 deferred; it clears only when a remote holds the tips (a pushed backup), or with `--allow-unbacked`. Surface any refusal's reason; for a submodule the options are Finish or Keep. Relay any `warnings` (relative symlinks that pointed outside the session no longer resolve after the move), and when the result reports `unbacked` entries, say plainly that those commits now exist only on this machine.
41
45
  - **Remove** (only an ORPHAN_SHELL the inventory marked empty — no worktree, nothing but empty directories, so there is nothing to archive). The command re-verifies emptiness itself and refuses, touching nothing, if any file or symlink has appeared since the inventory; a refusal means switch to Archive:
42
46
  ```bash
43
47
  node -e "const fs=require('fs');const p=process.argv[1];const empty=d=>fs.readdirSync(d,{withFileTypes:true}).every(e=>e.isDirectory()&&empty(d+'/'+e.name));if(!empty(p)){console.error(p+' is not empty — left alone');process.exit(1)}fs.rmSync(p,{recursive:true});console.log('removed empty shell '+p)" work-sessions/{name}
44
48
  ```
45
- - **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle.
49
+ - **Keep** (typical for ACTIVE, and the only sane answer for UNKNOWN) — leave it; it completes later under the session lifecycle. A kept session cannot be converted to a task in place — no converter exists, and the lifecycles keep their state differently (a session folder with a tracker vs. a branch with a chat-record entry). The supported equivalent: finish the session (merge it) and start the remaining work as a task, or keep it under the session lifecycle until it is done. Do not improvise a conversion by hand.
46
50
 
47
- Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one. Say so when you archive such a session, and offer the backup.
51
+ Unbacked commits (present on no remote) are safe in an archive — they are only ever at risk when someone deletes one. That is why the backup decision is a gate rather than a suggestion: the archive itself refuses until the backup step ran or the decline is recorded, and its result names what rode along.
48
52
 
49
53
  ## 3. Switch — never write the launcher's tracked `workspace.json` directly
50
54
 
@@ -34,9 +34,9 @@ ANY remote — even one you cannot push to — routes the update through a workt
34
34
 
35
35
  - **A remote exists (the normal case)** — create a task worktree up front and treat it as the workspace root for Steps 2–6:
36
36
  ```bash
37
- node .claude/scripts/task-worktree.mjs --root . --create --repo . --branch chore/template-update-{version}
37
+ node {scripts}/task-worktree.mjs --root . --create --repo . --branch chore/template-update-{version}
38
38
  ```
39
- The payload is untracked, so it does not appear inside the worktree — keep referencing it at the launcher's absolute path (`{launcher}/.workspace-update`). Everything the update needs travels with the payload, including any `.template-baseline.reconstructed.json` the CLI staged for a pre-baseline workspace. Step 7 commits, pushes, and opens the PR/MR from the worktree.
39
+ `{scripts}` is `.claude/scripts` when the workspace has the script, `{payload}/.claude/scripts` when it doesn't — pre-0.18 workspaces predate the task scripts entirely, and the payload always carries them (the same rule Step 2 already uses for the classifier). The payload is untracked, so it does not appear inside the worktree — keep referencing it at the launcher's absolute path (`{launcher}/.workspace-update`). Everything the update needs travels with the payload, including any `.template-baseline.reconstructed.json` the CLI staged for a pre-baseline workspace. Step 7 commits, pushes, and opens the PR/MR from the worktree.
40
40
  - **No remote** — apply in place. The Step 7 commit lands on the launcher's default branch: the one sanctioned launcher commit, because a repo with no remote has nowhere else for a template update to go. The payload path is `.workspace-update/`.
41
41
 
42
42
  In the commands below, `{payload}` is `.workspace-update` in the no-remote flow and `{launcher}/.workspace-update` in the worktree flow.
@@ -59,8 +59,9 @@ It runs from the payload precisely so workspaces that don't have it installed ye
59
59
  - `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied
60
60
  - `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list
61
61
  - `activated` — the payload ships `rules/{name}.md.skip` while the workspace keeps `{name}.md` active: the rule was deliberately activated. Nothing to install — the active rule stays.
62
- - `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically, so only real template removals are listed.
62
+ - `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically. An entry is a plain path, `{ file, referencedBy }` — a hook a workspace-only `settings.json` entry still registers (the paths say where) — or `{ file, userOwned: true }` — no baseline record, so the template never shipped it and it is the workspace's own.
63
63
  - `staleTests` — `*.test.mjs` files under `.claude/` the payload doesn't carry. The package never ships tests, so these came from a dev checkout and no update refreshes them (Step 3 offers removal).
64
+ - `implicitDefaults` — `workspace.json` keys whose absence carried a default in the version being upgraded from. Today: `canonicalBudgetBytes`, an implicit 40960-byte budget between v0.15.0-beta.1 and v0.19.0-beta.0 (before v0.15 there was no budget at all; since v0.19 absent means off). An upgrade from inside that window into a workspace.json that never set the key reports `{ key, value, reason }` — Step 3's workspace.json step writes the value explicitly so trimming doesn't silently stop.
64
65
 
65
66
  Content is compared with line endings normalized (CRLF ≡ LF; binary files byte-exact), so a Windows autocrlf checkout does not read as locally modified.
66
67
 
@@ -73,7 +74,7 @@ Report with version info from the manifest:
73
74
  "Template update: v{fromVersion} → v{templateVersion}. {N} new files, {U} template-updated, {M} locally modified, {C} config files to merge, {L} local-only edits, {D} deleted locally, {A} activated rules, {R} removed files, {K} unchanged."
74
75
  ```
75
76
 
76
- If `new`, `updated`, `differs`, `config` (an entry with empty `added`/`workspaceOnly`/`changed` lists and no array elements to merge counts as empty), `deletedLocally`, `activated`, and `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.)
77
+ If `new`, `updated`, `differs`, `config` (an entry with empty `added`/`workspaceOnly`/`changed` lists and no array elements to merge counts as empty), `deletedLocally`, `activated`, `removed`, and `implicitDefaults` are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed." (`localOnly` files are informational and `staleTests` may still be worth offering.)
77
78
 
78
79
  ### Step 2b: Historical .gitignore safety check
79
80
 
@@ -101,19 +102,19 @@ Batch the safe cases, ask on the rest:
101
102
  - **Config files (`config`):** `.mcp.json` and `.claude/settings.json` are never copied wholesale — a batch copy wipes the workspace's own MCP servers and settings. Merge each entry key by key (values from `{payload}/{path}` and the workspace's copy): add every `added` key, keep every `workspaceOnly` key untouched, and for each `changed` key ask — "Template changed `{key}` in `{path}`. Take the template's, keep yours, or inspect?" Array-valued keys merge as a union, no ask: keep the workspace's elements in place and append each `arrays` entry's `added` elements (`workspaceOnly` elements are already in place, listed for visibility). `notInstalled` — ask once: "Install {path} from the template? [Y/n]" (never install a config silently); `unparseable` means broken JSON on one side — show the file and ask, never merge blind.
102
103
  - **Local-only edits (`localOnly`):** nothing to decide — these are your local edits to files the template hasn't changed since the last update. List them in the summary (so the edits are visible) and move on; do not ask about them.
103
104
  - **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list. Restoring installs the payload's version of each.
104
- - **Removed in template (`removed`):** "Template removed {file}. Delete locally? [y/N]" — conservative default.
105
+ - **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in `workspace.json` → `workspace.localFiles` instead, showing the exact entry (`"localFiles": ["skills/my-skill/**"]`, or `["rules/my-rule.md"]` for a single file) so future updates skip it.
105
106
  - **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
106
107
  - **Stale tests (`staleTests`):** "These {N} test files under .claude/ came from a dev checkout — the package never ships them, so updates can't refresh them (tests live in the template repo). Remove them? [Y/n]" — one confirmation for the whole list.
107
108
  - **Hook migration (.sh to .mjs):** Detect old `.sh` hooks in `.claude/hooks/` that have `.mjs` replacements in the payload. Offer: "Hook {name}.sh has a .mjs replacement in the update. Replace and update settings.json commands? [Y/n]" — this is a one-time migration for workspaces upgrading from pre-0.2.0
108
109
 
109
110
  Also handle these non-component files from the payload:
110
111
 
111
- - **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.
112
+ - **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
112
113
  - **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, merge — never regenerate from scratch:
113
114
  ```bash
114
115
  node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --merge-claude-md
115
116
  ```
116
- The command prints the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result. (The two JSON configs are the `config` list's, not this block's.)
117
+ The command prints JSON `{ claudeMd, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
117
118
  - **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines. An ignore pattern does not untrack already-committed files: if the workspace still tracks the per-machine catalogs the template now ignores (`git ls-files -- 'workspace-context/team-member/*/index.md'`), untrack them (`git rm -r --cached 'workspace-context/team-member/*/index.md'`), or every machine's regenerations keep dirtying pulls.
118
119
 
119
120
  ### Step 4: Update version and write the baseline
@@ -176,7 +177,7 @@ node {payload}/.claude/scripts/maintenance-audit.mjs --root . --changed workspac
176
177
 
177
178
  Run it from the payload for the same reason as the classifier in Step 2: the workspace's own copy may predate this update. It reuses the sections of `/maintenance` audit that a script can decide (cross-references, frontmatter, structure, git state, catalog integrity, budgets, freshness) and marks findings on changed files `(from this update)`.
178
179
 
179
- Present its report as the verification result. Git-state warnings are expected here — the update's commit hasn't landed yet (Step 7). Delete the temp changed list afterwards.
180
+ Present its report as the verification result. Git-state warnings are expected here — the update's commit hasn't landed yet (Step 7) — and the launcher's replaced `workspace-update` skill copy is reported as info while it matches the payload's. Missing `@local-only-*` imports are info too (machine-local files never appear inside the worktree). Delete the temp changed list afterwards.
180
181
 
181
182
  - Findings labeled `(from this update)` — caused by this update; fix before committing (usually a new skill missing from CLAUDE.md's list, or a stale catalog).
182
183
  - Other findings — pre-existing; mention briefly.
@@ -197,12 +198,24 @@ Where the commit lands was decided in Step 1 — the launcher's default branch i
197
198
  git add -A
198
199
  git commit -m "chore: update workspace from template v{fromVersion} to v{templateVersion}"
199
200
  ```
200
- - **Remote exists:** from the task worktree created in Step 1, commit the applied update, push the branch, and open the PR/MR through `node .claude/scripts/task-pr.mjs` — it drives GitHub today and GitLab once gh:185 lands; if it reports the forge unsupported, open the MR with the forge's own CLI (for GitLab, `glab mr create`) from the worktree and say so in the report. After the PR/MR merges, restore the launcher's skill copy before pulling — `--upgrade` replaced `.claude/skills/workspace-update/` in the launcher (a tracked modification) and the merged PR delivers the same content, so a dirty launcher blocks the pull. If `.claude/skills/workspace-update/SKILL.md.local-backup` sits there (a customised skill the CLI backed up), move it somewhere safe first (e.g. `workspace-scratchpad/`), then:
201
- ```bash
202
- git -C {launcher} checkout -- .claude/skills/workspace-update
203
- git -C {launcher} clean -f -- .claude/skills/workspace-update
204
- ```
205
- Then pull the launcher and delete the payload (Step 6).
201
+ - **Remote exists:** from the task worktree created in Step 1, commit the applied update, push the branch, and open the PR/MR through `node {scripts}/task-pr.mjs` (`{scripts}` resolves as in Step 1 — the payload's copy whenever the workspace's own is missing). The workspace repo is addressed as `.`, so pass its PR body as `--body-file ".={path}"` (a repo with commits to merge but no body file is an error). If task-pr reports the forge unsupported, open the MR with the forge's own CLI (for GitLab, `glab mr create`) from the worktree and say so in the report. After the PR/MR merges, at the launcher and in this order:
202
+ 1. Restore the bootstrapped skill — `--upgrade` replaced `.claude/skills/workspace-update/` in the launcher (a tracked modification) and the merged PR delivers the same content, so a dirty launcher blocks the pull. If `SKILL.md.local-backup` sits there (a customised skill the CLI backed up), move it somewhere safe first (e.g. `workspace-scratchpad/`), then:
203
+ ```bash
204
+ git -C {launcher} checkout -- .claude/skills/workspace-update
205
+ git -C {launcher} clean -f -- .claude/skills/workspace-update
206
+ ```
207
+ 2. `git -C {launcher} pull --ff-only`
208
+ 3. Rebuild the catalogs — the pull can delete the gitignored per-user `workspace-context/team-member/{user}/index.md` (tracked before this update, untracked by it) that `CLAUDE.local.md` imports:
209
+ ```bash
210
+ node {launcher}/.claude/scripts/build-workspace-context.mjs --write --root {launcher}
211
+ ```
212
+ 4. Remove the update worktree and its local branch:
213
+ ```bash
214
+ node {launcher}/.claude/scripts/task-worktree.mjs --root {launcher} --remove --repo . --branch chore/template-update-{version} --delete-branch
215
+ ```
216
+ 5. Delete the payload (Step 6).
217
+
218
+ Newly added skills appear only after this merge — start a new chat or `/reload` to pick them up.
206
219
 
207
220
  Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules or hooks changed."
208
221
 
@@ -212,7 +225,7 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
212
225
 
213
226
  ## Notes
214
227
 
215
- - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), and stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull
228
+ - The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), and stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull, rebuilds the per-user catalogs the pull may delete, and removes the update worktree with its branch
216
229
  - Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are asked per file; `config` files (`.mcp.json`, `.claude/settings.json`) are merged key by key (array-valued keys union-merged) and never copied wholesale; `localOnly` files are never asked about (local edits to files the template didn't touch)
217
230
  - Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys, and deliberately activated rules
218
231
  - 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