@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.
@@ -67,11 +67,25 @@
67
67
  // (machine-local), paths under .claude/worktrees/, and entries
68
68
  // of workspace.json → workspace.localFiles (array of
69
69
  // .claude/-relative paths or globs for files this workspace
70
- // owns) (gh:180)
70
+ // owns) (gh:180). Two markers refine the per-file offer
71
+ // (gh:190): `{ file, referencedBy }` — a removed hook that a
72
+ // workspace-only settings.json entry still registers (the
73
+ // config-diff paths); the skill removes file and settings
74
+ // entry together. `{ file, userOwned: true }` — no baseline
75
+ // entry, so the template never shipped it: the workspace's
76
+ // own, offered a workspace.localFiles entry, not deletion.
71
77
  // staleTests — *.test.mjs files under .claude/ with no payload counterpart.
72
78
  // The npm tarball ships no tests, so these came from a dev
73
79
  // checkout and are never updated by /workspace-update; the
74
80
  // skill offers to remove them (tests live in the template repo)
81
+ // implicitDefaults — workspace.json keys whose ABSENCE carried a default
82
+ // in the version being upgraded FROM but not in the payload's:
83
+ // canonicalBudgetBytes meant a 40960-byte budget when absent
84
+ // from v0.15.0-beta.1 until v0.19.0-beta.0 made it opt-in
85
+ // (absent since means off; before v0.15 there was no budget).
86
+ // An upgrade from inside that window into a workspace.json
87
+ // without the key reports { key, value, reason } so the skill
88
+ // writes the value explicitly (gh:190).
75
89
  //
76
90
  // Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
77
91
  // (which file it came from) and `baselineReconstructed`. The default
@@ -103,10 +117,15 @@
103
117
  // present as `updated` again next time; see
104
118
  // template-baseline.mjs. Throws rather than writing an
105
119
  // empty baseline.
106
- // --merge-claude-md print CLAUDE.md with the payload's CLAUDE.md.tmpl
107
- // merged in: template lines updated, the workspace's own
108
- // lines (custom skill entries, sections) kept. The skill
109
- // shows the diff against the current file before writing.
120
+ // --merge-claude-md print JSON { claudeMd, missingIncludes }: CLAUDE.md
121
+ // with the payload's CLAUDE.md.tmpl merged in — template
122
+ // lines updated, the workspace's own lines (custom skill
123
+ // entries, sections) kept — plus the `@{path}` include
124
+ // lines the merged file carries whose targets don't
125
+ // exist at the root (machine-local local-only-* targets
126
+ // exempt). The skill shows the diff against the current
127
+ // file before writing, and asks on each missing include
128
+ // instead of leaving it dangling (gh:190).
110
129
 
111
130
  import {
112
131
  existsSync,
@@ -118,6 +137,7 @@ import {
118
137
  import { basename, join, resolve } from 'node:path';
119
138
  import { fileURLToPath } from 'node:url';
120
139
  import { gitIgnoredPaths } from './build-workspace-context.mjs';
140
+ import { compareVersions } from '../lib/registry-check.mjs';
121
141
  import {
122
142
  BASELINE_PATH,
123
143
  RECONSTRUCTED_BASELINE_NAME,
@@ -354,6 +374,47 @@ export function resolveBaseline({ root, payload, baseline = null }) {
354
374
  return { baseline: null, source: null };
355
375
  }
356
376
 
377
+ /**
378
+ * workspace.json keys whose absence carried a default in the version being
379
+ * upgraded FROM but not in the payload's. The canonical budget existed as an
380
+ * implicit default only between v0.15.0-beta.1 (gh:97, which introduced it:
381
+ * absent meant a 40960-byte budget) and v0.19.0-beta.0 (gh:164, which made
382
+ * it opt-in: absent means off since). Before v0.15 there was no budget at
383
+ * all, so a workspace upgrading from there also has none to preserve —
384
+ * reporting the key would turn trimming ON. Only an upgrade from inside
385
+ * that window into a workspace.json that never wrote the key reports it,
386
+ * and the skill writes the explicit value and says so (gh:190).
387
+ */
388
+ const CANONICAL_BUDGET_INTRODUCED = '0.15.0-beta.1';
389
+ const CANONICAL_BUDGET_OPT_IN = '0.19.0-beta.0';
390
+ const CANONICAL_BUDGET_DEFAULT = 40960;
391
+
392
+ function implicitDefaults(absRoot, absPayload) {
393
+ let manifest;
394
+ try {
395
+ manifest = JSON.parse(readFileSync(join(absPayload, '.manifest.json'), 'utf8'));
396
+ } catch {
397
+ return []; // no manifest — the payload path is wrong; nothing to infer
398
+ }
399
+ const { fromVersion } = manifest;
400
+ if (typeof fromVersion !== 'string' || fromVersion === 'unknown') return [];
401
+ if (compareVersions(fromVersion, CANONICAL_BUDGET_INTRODUCED) < 0) return [];
402
+ if (compareVersions(fromVersion, CANONICAL_BUDGET_OPT_IN) >= 0) return [];
403
+ let config;
404
+ try {
405
+ config = JSON.parse(readFileSync(join(absRoot, 'workspace.json'), 'utf8'));
406
+ } catch {
407
+ return []; // no workspace.json to preserve a default in
408
+ }
409
+ const ws = config?.workspace && typeof config.workspace === 'object' ? config.workspace : null;
410
+ if (!ws || Object.prototype.hasOwnProperty.call(ws, 'canonicalBudgetBytes')) return [];
411
+ return [{
412
+ key: 'canonicalBudgetBytes',
413
+ value: CANONICAL_BUDGET_DEFAULT,
414
+ reason: `absent meant a ${CANONICAL_BUDGET_DEFAULT}-byte canonical budget before v0.19 and means off since — write the value explicitly or trimming silently stops`,
415
+ }];
416
+ }
417
+
357
418
  export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
358
419
  const absRoot = resolve(root);
359
420
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
@@ -376,6 +437,7 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
376
437
  activated: [],
377
438
  removed: [],
378
439
  staleTests: [],
440
+ implicitDefaults: [],
379
441
  hasBaseline: baseline !== null,
380
442
  baselineSource: source,
381
443
  baselineReconstructed: baseline !== null && baseline.reconstructed === true,
@@ -451,11 +513,71 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
451
513
  continue;
452
514
  }
453
515
  if (isOwnedByWorkspace(rel, localFiles)) continue;
454
- result.removed.push(rel);
516
+ // No baseline record means the template never shipped the file here —
517
+ // the workspace's own, not a template removal. Marked so the skill
518
+ // offers a workspace.localFiles entry instead of deletion; only a real
519
+ // baseline can prove the negative. An activated optional rule is the
520
+ // exception: the baseline records its .skip twin, which proves the
521
+ // template shipped it, so its removal stays plain (gh:190).
522
+ const templateShipped = typeof baseline?.files[rel] === 'string'
523
+ || (rel.startsWith('.claude/rules/') && rel.endsWith('.md')
524
+ && typeof baseline?.files[`${rel}.skip`] === 'string');
525
+ if (baseline && !templateShipped) {
526
+ result.removed.push({ file: rel, userOwned: true });
527
+ } else {
528
+ result.removed.push(rel);
529
+ }
455
530
  }
531
+ linkRemovedHooks(result, absRoot);
532
+ result.implicitDefaults = implicitDefaults(absRoot, absPayload);
456
533
  return result;
457
534
  }
458
535
 
536
+ /**
537
+ * A removed hook that a workspace-only settings.json entry still registers
538
+ * must not be deleted while its registration stays: mark the removal with
539
+ * `referencedBy` — the config-diff paths (`settings.json hooks.{Event}`) —
540
+ * so the skill removes the file and the settings entry together (gh:190).
541
+ * References are looked for where the config diff shows the workspace
542
+ * holding what the payload doesn't: `arrays[].workspaceOnly` elements and
543
+ * `workspaceOnly` keys (whose value is read from its settings.json).
544
+ */
545
+ function linkRemovedHooks(result, absRoot) {
546
+ const settings = result.config.find((c) => c.path === '.claude/settings.json');
547
+ if (!settings || settings.notInstalled || settings.unparseable) return;
548
+ const removedHooks = result.removed.filter(
549
+ (entry) => typeof entry === 'string' && entry.startsWith('.claude/hooks/'),
550
+ );
551
+ if (removedHooks.length === 0) return;
552
+ let wsHooks = null;
553
+ try {
554
+ const wsSettings = JSON.parse(readFileSync(join(absRoot, '.claude', 'settings.json'), 'utf8'));
555
+ if (isPlainObject(wsSettings?.hooks)) wsHooks = wsSettings.hooks;
556
+ } catch {
557
+ return; // the config entry already flagged it unparseable
558
+ }
559
+ for (let i = 0; i < result.removed.length; i++) {
560
+ const rel = result.removed[i];
561
+ if (typeof rel !== 'string' || !rel.startsWith('.claude/hooks/')) continue;
562
+ const referencedBy = [];
563
+ for (const arr of settings.arrays) {
564
+ if (arr.path.startsWith('hooks/') && arr.workspaceOnly.some((el) => JSON.stringify(el).includes(rel))) {
565
+ referencedBy.push(`settings.json ${arr.path.split('/').join('.')}`);
566
+ }
567
+ }
568
+ for (const path of settings.workspaceOnly) {
569
+ if (!path.startsWith('hooks/')) continue;
570
+ const event = wsHooks && wsHooks[path.split('/')[1]];
571
+ if (event !== undefined && JSON.stringify(event).includes(rel)) {
572
+ referencedBy.push(`settings.json ${path.split('/').join('.')}`);
573
+ }
574
+ }
575
+ if (referencedBy.length > 0) {
576
+ result.removed[i] = { file: rel, referencedBy };
577
+ }
578
+ }
579
+ }
580
+
459
581
  // ---------- CLAUDE.md merge ----------
460
582
 
461
583
  /**
@@ -606,6 +728,25 @@ function writeBaselineMode(args) {
606
728
  }, null, 2) + '\n');
607
729
  }
608
730
 
731
+ /**
732
+ * `@{path}` include lines in a CLAUDE.md body whose target file does not
733
+ * exist at the workspace root. Machine-local targets (`local-only-*`
734
+ * basenames) are expected absent on machines that never wrote them — the
735
+ * same exemption the maintenance audit gives those imports — so they never
736
+ * report. The include-line shape mirrors context-footprint's resolveImports:
737
+ * a line whose trimmed content is exactly `@` plus a path.
738
+ */
739
+ function missingIncludes(absRoot, text) {
740
+ const missing = [];
741
+ for (const rawLine of text.split(/\r?\n/)) {
742
+ const m = /^@(\S+)$/.exec(rawLine.trim());
743
+ if (!m) continue;
744
+ if (m[1].split('/').pop().startsWith('local-only-')) continue;
745
+ if (!existsSync(resolve(absRoot, m[1]))) missing.push(m[1]);
746
+ }
747
+ return missing;
748
+ }
749
+
609
750
  function mergeClaudeMdMode(args) {
610
751
  const absRoot = resolve(args.root);
611
752
  const absPayload = resolvePayload(args);
@@ -623,7 +764,14 @@ function mergeClaudeMdMode(args) {
623
764
  const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
624
765
  const claudeMdPath = join(absRoot, 'CLAUDE.md');
625
766
  const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
626
- process.stdout.write(mergeClaudeMd(current, next));
767
+ const claudeMd = mergeClaudeMd(current, next);
768
+ // The gained-@include check is deterministic, not something to eyeball in
769
+ // the diff: every include line whose target is absent here is reported so
770
+ // the skill asks (stub or omit) instead of writing it silently (gh:190).
771
+ process.stdout.write(JSON.stringify({
772
+ claudeMd,
773
+ missingIncludes: missingIncludes(absRoot, claudeMd),
774
+ }, null, 2) + '\n');
627
775
  }
628
776
 
629
777
  function main() {
@@ -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
- const add = (section, severity, file, message) => {
165
+ // opts.noFromUpdate: expected-absent findings (a machine-local import
166
+ // missing inside a task worktree) stay ambient info even when their file
167
+ // is on the --changed list — the update did not cause them (gh:190).
168
+ const add = (section, severity, file, message, opts = {}) => {
161
169
  const key = JSON.stringify([section, severity, file, message]);
162
170
  if (seenFindings.has(key)) return;
163
171
  seenFindings.add(key);
@@ -166,7 +174,7 @@ export async function runAudit({
166
174
  severity,
167
175
  file,
168
176
  message,
169
- ...(changedSet.has(file) ? { fromUpdate: true } : {}),
177
+ ...(changedSet.has(file) && !opts.noFromUpdate ? { fromUpdate: true } : {}),
170
178
  });
171
179
  };
172
180
 
@@ -323,7 +331,10 @@ export async function runAudit({
323
331
  const posix = toPosix(spec);
324
332
  const base = posix.split('/').pop();
325
333
  if (base.startsWith('local-only-')) {
326
- add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`);
334
+ // Machine-local files never materialize inside a task worktree (the
335
+ // update flow audits from one), so this is ambient, never something
336
+ // the update caused (gh:190).
337
+ add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`, { noFromUpdate: true });
327
338
  } else if (posix === 'CODEBASE.md') {
328
339
  add('cross-reference', 'info', 'CLAUDE.md', '@CODEBASE.md is absent — optional stub, /workspace-init generates it on request');
329
340
  } else if (isAutoFileRel(posix, wcDir)) {
@@ -379,6 +390,9 @@ export async function runAudit({
379
390
  return relToWC.split('/').includes('archive');
380
391
  };
381
392
 
393
+ // Resolved lifecycles are closed out, not defects — however many there
394
+ // are, they surface as ONE info line, not one per file (gh:190).
395
+ const resolvedFiles = [];
382
396
  for (let i = 0; i < files.length; i++) {
383
397
  const rel = rels[i];
384
398
  if (ignored.has(rel) || isHistorical(rel)) continue;
@@ -435,12 +449,17 @@ export async function runAudit({
435
449
  }
436
450
  }
437
451
  if (f.lifecycle === 'resolved') {
438
- add('frontmatter', 'info', rel, 'lifecycle resolved — confirm /complete-work has processed it');
452
+ resolvedFiles.push(rel);
439
453
  }
440
454
  if ('confidence' in f && !['high', 'medium', 'low'].includes(f.confidence)) {
441
455
  add('frontmatter', 'warning', rel, `confidence '${f.confidence}' is not one of high, medium, low`);
442
456
  }
443
457
  }
458
+ if (resolvedFiles.length > 0) {
459
+ const names = resolvedFiles.slice(0, 3).map((rel) => rel.split('/').pop());
460
+ add('frontmatter', 'info', wcDir,
461
+ `${resolvedFiles.length} lifecycle resolved file(s) — confirm /complete-work has processed them (${names.join(', ')}${resolvedFiles.length > 3 ? ', …' : ''})`);
462
+ }
444
463
  })();
445
464
 
446
465
  // ---------- 4. git state ----------
@@ -463,11 +482,31 @@ export async function runAudit({
463
482
  add('git', 'warning', '.',
464
483
  `launcher is on branch '${gitInfo.launcherBranch}' — it stays on its default branch ('${gitInfo.defaultBranch}')`);
465
484
  }
485
+ // --upgrade replaces the launcher's workspace-update skill before the
486
+ // payload is applied, and the merged PR delivers the same content back:
487
+ // a modification that equals the staged payload's copy is the expected
488
+ // bootstrap, not drift — info, and out of the dirty warning (gh:190).
489
+ const porcelainPath = (line) => line.slice(line.indexOf(' ') + 1).split(' -> ')[0];
466
490
  const launcherDirty = gitInfo.launcherPorcelain.filter((l) => !l.startsWith('??'));
467
- if (launcherDirty.length > 0) {
468
- const paths = launcherDirty.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
491
+ const bootstrap = launcherDirty.filter((l) => {
492
+ const p = porcelainPath(l);
493
+ if (!p.startsWith('.claude/skills/workspace-update/')) return false;
494
+ try {
495
+ return hashBytes(readFileSync(join(gitInfo.launcherRoot, p)))
496
+ === hashBytes(readFileSync(join(gitInfo.launcherRoot, '.workspace-update', p)));
497
+ } catch {
498
+ return false; // no staged payload copy to compare against
499
+ }
500
+ });
501
+ if (bootstrap.length > 0) {
502
+ add('git', 'info', '.',
503
+ "launcher's .claude/skills/workspace-update/ replaced by --upgrade (matches the staged payload) — expected until the update merges");
504
+ }
505
+ const drift = launcherDirty.filter((l) => !bootstrap.includes(l));
506
+ if (drift.length > 0) {
507
+ const paths = drift.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
469
508
  add('git', 'warning', '.',
470
- `launcher has ${launcherDirty.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${launcherDirty.length > 5 ? ', …' : ''}`);
509
+ `launcher has ${drift.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${drift.length > 5 ? ', …' : ''}`);
471
510
  }
472
511
  add('git', 'info', '.',
473
512
  'uncommitted-changes check skipped — the worktree is expected to carry in-flight changes');
@@ -45,7 +45,11 @@
45
45
  // folder is renamed into {sessions}/.archived/ and git's
46
46
  // worktree links are repaired to follow it. Refuses when a
47
47
  // worktree holds uncommitted changes (an edited session.md
48
- // counts) unless --allow-uncommitted says leave them be.
48
+ // counts) unless --allow-uncommitted says leave them be,
49
+ // and when a worktree tip holds commits no remote backs
50
+ // (they exist only on this machine) unless --allow-unbacked
51
+ // records that the operator saw the counts and declined
52
+ // the backup.
49
53
  // --enable-task-model
50
54
  // flip workspace.sessionModel to "task" (accepts a task
51
55
  // worktree root — the one mode allowed off the launcher)
@@ -585,8 +589,9 @@ function readTracker(wsDir) {
585
589
  : rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
586
590
  // A chat session with ended: null may still be open — session-start
587
591
  // records one per chat and session-end fills ended in. Counting them
588
- // is how the inventory flags "a chat may be working in this session
589
- // right now" before anyone proposes archiving it.
592
+ // is the raw material for the chat-open flag; whether the count means
593
+ // "a chat may be working here right now" is decided against the
594
+ // session's recency in inspectSession, not here.
590
595
  const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
591
596
  const openChats = chats.filter((c) => c && typeof c === 'object' && (c.ended == null || c.ended === '')).length;
592
597
  return {
@@ -873,6 +878,14 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
873
878
  // mid-completion), while an unparseable file is tracker damage the
874
879
  // operator should hear about. Neither ever crashes the inspection.
875
880
  const trackerStripped = !existsSync(join(wsDir, 'session.md'));
881
+ // The tracker file's own mtime: a chat resuming the session rewrites
882
+ // session.md (the session-start hook registers it in chatSessions), so a
883
+ // fresh mtime is live-chat evidence even when no commit or reflog entry
884
+ // followed. It feeds only the chat-open question below, never
885
+ // lastActivity — an uncommitted tracker edit is bookkeeping, not work
886
+ // (same line dirtyContentMtimeMs draws).
887
+ let trackerMtimeMs = null;
888
+ try { trackerMtimeMs = statSync(join(wsDir, 'session.md')).mtimeMs; } catch { /* absent or unreadable — no signal */ }
876
889
  const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
877
890
  const worktrees = rawWorktrees
878
891
  .filter((w) => w.kind !== 'foreign')
@@ -905,11 +918,36 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
905
918
 
906
919
  const { proposal, reasons } = classify({ name, worktrees, lastActivity, trackerStripped }, activeDays, now);
907
920
  const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
921
+ // A chat with no recorded end only means "may still be open" while
922
+ // something in the session is also recent: session-end misses often
923
+ // enough (a crashed chat, a skipped hook) that an entry sitting on a
924
+ // session idle for months is a stale record, not a live chat. Recency is
925
+ // the newest of the session's own activity signals (lastActivity —
926
+ // own-branch commits, reflog, dirty content, the tracker's updated
927
+ // field) and the tracker file's mtime. When no signal can establish
928
+ // either answer, the cautious reading stands.
929
+ let chatOpen = false;
930
+ let chatIdleDays = null;
908
931
  if (tracker && tracker.openChats > 0) {
909
- warnings.push({
910
- kind: 'chat-open',
911
- message: `${tracker.openChats} chat session(s) recorded with no end time — a chat may still be working in this session; confirm with the operator before archiving it`,
912
- });
932
+ const newestChatSignal = Math.max(
933
+ Number.isFinite(lastMs) ? lastMs : -Infinity,
934
+ trackerMtimeMs ?? -Infinity,
935
+ );
936
+ if (!Number.isFinite(newestChatSignal) || now - newestChatSignal <= activeDays * DAY_MS) {
937
+ chatOpen = true;
938
+ warnings.push({
939
+ kind: 'chat-open',
940
+ message: `${tracker.openChats} chat session(s) recorded with no end time — a chat may still be working in this session; confirm with the operator before archiving it`,
941
+ });
942
+ } else {
943
+ chatIdleDays = Math.floor((now - newestChatSignal) / DAY_MS);
944
+ warnings.push({
945
+ kind: 'chat-open-idle',
946
+ info: true,
947
+ idleDays: chatIdleDays,
948
+ message: `${tracker.openChats} chat session(s) recorded with no end time, but nothing in the session has moved for ${chatIdleDays} day(s) — the end was most likely never recorded; treat the chat as closed unless the operator knows otherwise`,
949
+ });
950
+ }
913
951
  }
914
952
  if (!trackerStripped && !tracker) {
915
953
  warnings.push({
@@ -922,7 +960,8 @@ function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now, fetc
922
960
  kind: 'session',
923
961
  status: tracker?.status ?? null,
924
962
  workItem: tracker?.workItem ?? null,
925
- chatOpen: tracker ? tracker.openChats > 0 : false,
963
+ chatOpen,
964
+ ...(chatIdleDays != null ? { chatIdleDays } : {}),
926
965
  lastActivity,
927
966
  proposal,
928
967
  reasons,
@@ -1753,12 +1792,17 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1753
1792
  * find; a worktree holds uncommitted or untracked changes — an edited
1754
1793
  * session.md counts (unless allowUncommitted: they would ride along
1755
1794
  * fine, but they deserve a decision: commit them to the session branch,
1756
- * or explicitly accept archiving them mid-edit); or the archive
1757
- * directory is a symlink or resolves outside the workspace. If anything
1758
- * fails after the rename, the folder is renamed back and repaired, and
1759
- * the result reports the verified state.
1795
+ * or explicitly accept archiving them mid-edit); or a worktree tip holds
1796
+ * commits no remote backs — they exist only on this machine, and the
1797
+ * archive is safe for them but the moment it is deleted they are gone
1798
+ * (unless allowUnbacked, the operator's recorded decline after seeing
1799
+ * the per-repo counts; the refusal names them, and a successful archive
1800
+ * that carried unbacked tips reports them in `unbacked`). Also refused
1801
+ * when the archive directory is a symlink or resolves outside the
1802
+ * workspace. If anything fails after the rename, the folder is renamed
1803
+ * back and repaired, and the result reports the verified state.
1760
1804
  */
1761
- function archiveSession(root, { session, allowUncommitted = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1805
+ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1762
1806
  const rootDir = resolveRoot(root);
1763
1807
  if (!isSessionSegment(session)) {
1764
1808
  throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
@@ -1824,6 +1868,30 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1824
1868
  }
1825
1869
  }
1826
1870
  }
1871
+ // Unpushed commits ride along safely too — but "safely" holds only as
1872
+ // long as the archive exists, and deleting it later is the operator's
1873
+ // own call. A tip whose commits no remote backs (the inventory's
1874
+ // `unbacked` evidence, recomputed here at archive time) exists only on
1875
+ // this machine, so the archive moves only once a remote holds every
1876
+ // such tip — the backup step's push mode --remote with an allow — or
1877
+ // after --allow-unbacked records that the operator saw these counts and
1878
+ // declined it. allowUnbacked still reports what rode along.
1879
+ const unbacked = [];
1880
+ for (const f of found) {
1881
+ const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
1882
+ const info = inspectWorktree(
1883
+ gitFn, rootDir,
1884
+ f.owner.repo === WORKSPACE_REPO ? 'workspace' : 'project',
1885
+ f.owner.repo, wtPath, null,
1886
+ );
1887
+ if (info.ahead > 0 && !info.backedBy) unbacked.push(info);
1888
+ }
1889
+ if (unbacked.length > 0 && !allowUnbacked) {
1890
+ reasons.push(
1891
+ ...unbacked.map((wt) => unbackedMessage(gitFn, rootDir, wt)),
1892
+ `${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (--backup --remote with the operator's allow pushes backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
1893
+ );
1894
+ }
1827
1895
  if (reasons.length > 0) return { refused: true, reasons };
1828
1896
 
1829
1897
  const archiveDir = join(sessionsDir, '.archived');
@@ -1867,6 +1935,9 @@ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawn
1867
1935
  from: relative(rootDir, folder),
1868
1936
  to: relative(rootDir, dest),
1869
1937
  worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
1938
+ // What the operator accepted riding along unbacked — the same
1939
+ // repos and counts the refusal would have named.
1940
+ ...(unbacked.length > 0 ? { unbacked: unbacked.map((wt) => ({ repo: wt.repo, branch: wt.branch, commits: wt.ahead })) } : {}),
1870
1941
  warnings: scan.outwardLinks.map((l) => `relative symlink ${relative(rootDir, join(dest, relative(folder, l)))} pointed outside the session and no longer resolves after the move — it was kept as-is`),
1871
1942
  };
1872
1943
  }
@@ -1927,7 +1998,7 @@ function renderTable(result) {
1927
1998
  : s.kind === 'foreign' ? 'foreign entry' : s.proposal;
1928
1999
  const detail = s.kind === 'broken' || s.kind === 'foreign'
1929
2000
  ? ''
1930
- : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : ''})`;
2001
+ : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'}${s.chatOpen ? ', chat open?' : s.chatIdleDays != null ? `, chat: no end recorded (idle ${s.chatIdleDays}d)` : ''})`;
1931
2002
  lines.push(`${s.name} ${header}${detail}`);
1932
2003
  for (const w of s.worktrees || []) {
1933
2004
  const remotes = Object.entries(w.remotes)
@@ -1952,7 +2023,9 @@ function renderTable(result) {
1952
2023
  }
1953
2024
  }
1954
2025
  for (const r of s.reasons || []) lines.push(` · ${r}`);
1955
- for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
2026
+ // `!` marks something to act on; an entry carrying info: true is
2027
+ // context (a stale record explained, not a live risk).
2028
+ for (const w of s.warnings || []) lines.push(` ${w.info ? 'i' : '!'} ${w.message}`);
1956
2029
  }
1957
2030
  if (Array.isArray(result.externalWorktrees) && result.externalWorktrees.length > 0) {
1958
2031
  lines.push('');
@@ -1976,6 +2049,7 @@ const BOOL_FLAGS = new Map([
1976
2049
  ['--remote', 'remote'],
1977
2050
  ['--remote-allow-all', 'remoteAllowAll'],
1978
2051
  ['--allow-uncommitted', 'allowUncommitted'],
2052
+ ['--allow-unbacked', 'allowUnbacked'],
1979
2053
  ['--fetch', 'fetch'],
1980
2054
  ]);
1981
2055
 
@@ -1990,6 +2064,7 @@ function parseArgs(argv) {
1990
2064
  remote: false,
1991
2065
  remoteAllowAll: false,
1992
2066
  allowUncommitted: false,
2067
+ allowUnbacked: false,
1993
2068
  fetch: false,
1994
2069
  };
1995
2070
  const rest = argv.slice(2);
@@ -2045,6 +2120,9 @@ function parseArgs(argv) {
2045
2120
  if (args.allowUncommitted && args.mode !== 'archive') {
2046
2121
  throw new Error('--allow-uncommitted is only valid with --archive');
2047
2122
  }
2123
+ if (args.allowUnbacked && args.mode !== 'archive') {
2124
+ throw new Error('--allow-unbacked is only valid with --archive');
2125
+ }
2048
2126
  if (args.dryRun && args.mode !== 'backup') {
2049
2127
  throw new Error('--dry-run is only valid with --backup');
2050
2128
  }
@@ -2073,7 +2151,11 @@ function main() {
2073
2151
  dryRun: args.dryRun,
2074
2152
  });
2075
2153
  } else if (args.mode === 'archive') {
2076
- out = archiveSession(rootDir, { session: args.session, allowUncommitted: args.allowUncommitted });
2154
+ out = archiveSession(rootDir, {
2155
+ session: args.session,
2156
+ allowUncommitted: args.allowUncommitted,
2157
+ allowUnbacked: args.allowUnbacked,
2158
+ });
2077
2159
  } else {
2078
2160
  out = enableTaskModel(rootDir);
2079
2161
  }