@ulysses-ai/create-workspace 0.23.0-beta.0 → 0.23.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/upgrade.mjs +127 -41
- package/lib/upgrade.test.mjs +138 -14
- package/package.json +1 -1
- package/template/_claude/rules/workspace-structure.md +2 -1
- package/template/_claude/scripts/classify-update.mjs +155 -7
- package/template/_claude/scripts/forges/gitlab.mjs +6 -1
- package/template/_claude/scripts/maintenance-audit.mjs +47 -8
- package/template/_claude/scripts/migrate-sessions.mjs +98 -16
- package/template/_claude/scripts/template-merge.mjs +255 -0
- package/template/_claude/skills/migrate-sessions/SKILL.md +10 -6
- package/template/_claude/skills/workspace-update/SKILL.md +40 -19
|
@@ -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() {
|
|
@@ -123,7 +123,10 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
123
123
|
// workspace MR and closes the issue ahead of CI ever publishing).
|
|
124
124
|
// --auto-merge=false makes glab merge now or fail, and the post-merge
|
|
125
125
|
// view verifies the merge actually happened — glab exiting 0 is not
|
|
126
|
-
// proof on its own.
|
|
126
|
+
// proof on its own. A project (or a whole group) may also require the
|
|
127
|
+
// merge to name the MR's current head SHA, so the pre-merge view's SHA
|
|
128
|
+
// rides along as --sha; when the view reports none the merge proceeds
|
|
129
|
+
// without it rather than failing — permissive projects still merge.
|
|
127
130
|
async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
|
|
128
131
|
if (!id) throw new Error('prMerge: id is required');
|
|
129
132
|
const { number, repo: parsedRepo } = parseMrId(id, repoFor(repo));
|
|
@@ -132,6 +135,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
132
135
|
throw new MergeRejected(id, 'MR is a draft — mark it ready before merging');
|
|
133
136
|
}
|
|
134
137
|
const args = ['mr', 'merge', String(number), '--repo', parsedRepo, '--yes', '--auto-merge=false'];
|
|
138
|
+
if (before.headSha) args.push('--sha', before.headSha);
|
|
135
139
|
switch (strategy) {
|
|
136
140
|
case 'merge': break; // plain merge is glab's default
|
|
137
141
|
case 'squash': args.push('--squash'); break;
|
|
@@ -183,6 +187,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
183
187
|
mergeStateStatus: raw.detailed_merge_status ?? raw.merge_status ?? null,
|
|
184
188
|
reviewDecision: '',
|
|
185
189
|
headRefName: raw.source_branch,
|
|
190
|
+
headSha: raw.sha ?? null,
|
|
186
191
|
baseRefName: raw.target_branch,
|
|
187
192
|
isDraft: !!raw.draft,
|
|
188
193
|
mergedAt: raw.merged_at,
|
|
@@ -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
|
}
|