@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 +1 -1
- package/template/_claude/scripts/classify-update.mjs +155 -7
- package/template/_claude/scripts/maintenance-audit.mjs +47 -8
- package/template/_claude/scripts/migrate-sessions.mjs +98 -16
- package/template/_claude/skills/migrate-sessions/SKILL.md +10 -6
- package/template/_claude/skills/workspace-update/SKILL.md +28 -15
package/package.json
CHANGED
|
@@ -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
|
|
107
|
-
//
|
|
108
|
-
// lines
|
|
109
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
const
|
|
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 ${
|
|
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
|
|
589
|
-
// right now"
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
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
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
* the
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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) —
|
|
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}
|
|
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.
|
|
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
|
|
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,
|
|
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 `
|
|
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]"
|
|
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:**
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|