@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.19.0-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.
Files changed (117) hide show
  1. package/README.md +3 -3
  2. package/lib/init.mjs +4 -1
  3. package/lib/payload.mjs +18 -1
  4. package/lib/payload.test.mjs +55 -0
  5. package/lib/scaffold.mjs +23 -6
  6. package/lib/scaffold.test.mjs +59 -0
  7. package/package.json +1 -1
  8. package/template/CLAUDE.md.tmpl +19 -2
  9. package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
  10. package/template/_claude/hooks/repo-write-detection.mjs +204 -0
  11. package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
  12. package/template/_claude/hooks/subagent-start.mjs +111 -0
  13. package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
  14. package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
  15. package/template/_claude/rules/forge-operations.md +57 -0
  16. package/template/_claude/rules/git-conventions.md +39 -0
  17. package/template/_claude/rules/goal-driven-work.md +24 -0
  18. package/template/_claude/rules/honest-pushback.md +56 -0
  19. package/template/_claude/rules/memory-guidance.md +66 -0
  20. package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
  21. package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
  22. package/template/_claude/rules/work-item-tracking.md +48 -0
  23. package/template/_claude/rules/workspace-structure.md +79 -0
  24. package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
  25. package/template/_claude/scripts/chat-record.mjs +315 -0
  26. package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
  27. package/template/_claude/scripts/context-footprint.mjs +391 -0
  28. package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
  29. package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
  30. package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
  31. package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
  32. package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
  33. package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
  34. package/template/_claude/scripts/task-pr.mjs +447 -0
  35. package/template/_claude/scripts/task-worktree.mjs +525 -0
  36. package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
  37. package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
  38. package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
  39. package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
  40. package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
  41. package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  42. package/template/_claude/skills/complete-work/SKILL.md +452 -0
  43. package/template/_claude/skills/context-placement/SKILL.md +202 -0
  44. package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
  45. package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
  46. package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
  47. package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
  48. package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
  49. package/template/_claude/skills/release/SKILL.md +91 -0
  50. package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
  51. package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
  52. package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
  53. package/template/_gitignore +9 -0
  54. package/template/workspace.json.tmpl +4 -3
  55. package/template/.claude/hooks/repo-write-detection.mjs +0 -107
  56. package/template/.claude/hooks/subagent-start.mjs +0 -44
  57. package/template/.claude/rules/forge-operations.md +0 -107
  58. package/template/.claude/rules/git-conventions.md +0 -34
  59. package/template/.claude/rules/honest-pushback.md +0 -56
  60. package/template/.claude/rules/memory-guidance.md +0 -109
  61. package/template/.claude/rules/work-item-tracking.md +0 -90
  62. package/template/.claude/rules/workspace-structure.md +0 -137
  63. package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
  64. package/template/.claude/skills/complete-work/SKILL.md +0 -498
  65. package/template/.claude/skills/release/SKILL.md +0 -151
  66. /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
  67. /package/template/{.claude → _claude}/agents/implementer.md +0 -0
  68. /package/template/{.claude → _claude}/agents/researcher.md +0 -0
  69. /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
  70. /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
  71. /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
  72. /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
  73. /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
  74. /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
  75. /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
  76. /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
  77. /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
  78. /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
  79. /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
  80. /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
  81. /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
  82. /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
  83. /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
  84. /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
  85. /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
  86. /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
  87. /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
  88. /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
  89. /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
  90. /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
  91. /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
  92. /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
  93. /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
  94. /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
  95. /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
  96. /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
  97. /package/template/{.claude → _claude}/settings.json +0 -0
  98. /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
  99. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
  100. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
  101. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
  102. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
  103. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
  104. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
  105. /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
  106. /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
  107. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
  108. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
  109. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
  110. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
  111. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
  112. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
  113. /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
  114. /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
  115. /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
  116. /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
  117. /package/template/{.mcp.json → _mcp.json} +0 -0
@@ -9,12 +9,14 @@
9
9
  // Source of truth: the filesystem. Hand edits are overwritten on regeneration.
10
10
  // Gitignored files are excluded automatically. .indexignore adds prefix excludes.
11
11
  //
12
- // Canonical files honor a configurable byte budget. Each locked file declares
12
+ // Canonical files honor an opt-in byte budget. Each locked file declares
13
13
  // `priority: critical | reference` in its frontmatter (default: critical).
14
14
  // Section-level `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` markers
15
- // fence droppable spans inside reference files. When canonical body bytes exceed
16
- // the budget, the builder trims reference files first, then stubs them, in that
17
- // deterministic order. Critical files are never modified.
15
+ // fence droppable spans inside reference files. When a budget is set and the
16
+ // canonical body exceeds it, the builder trims reference files first, then
17
+ // stubs them, in that deterministic order. Critical files are never modified.
18
+ // `workspace.canonicalBudgetBytes` is off unless it holds a number: absent or
19
+ // null means every locked file ships in full and nothing is trimmed.
18
20
  //
19
21
  // Usage:
20
22
  // node build-workspace-context.mjs --write [--root <workspace-root>]
@@ -25,10 +27,11 @@
25
27
  // 0 — all artifacts current and canonical within budget
26
28
  // 1 — at least one artifact missing or stale (regenerate via --write)
27
29
  // 2 — artifacts current, but canonical body exceeds budget after trimming and stubbing
30
+ // (only reachable when a budget is set; off means nothing can exceed it)
28
31
  // Stale wins over over-budget when both apply.
29
32
 
30
33
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
31
- import { join, relative, sep } from 'node:path';
34
+ import { join, relative, resolve, sep } from 'node:path';
32
35
  import { spawnSync } from 'node:child_process';
33
36
  import { fileURLToPath } from 'node:url';
34
37
  import { parseSessionContent } from '../lib/session-frontmatter.mjs';
@@ -129,6 +132,15 @@ function gitIgnoredPaths(workspaceRoot, paths) {
129
132
  );
130
133
  }
131
134
 
135
+ // The git filter fails open: gitIgnoredPaths returns an empty set when git is missing,
136
+ // the directory is not a repository, or the subprocess errors. Canonical content is
137
+ // committed and broadcast to every session and subagent, so that path must not depend on a
138
+ // subprocess succeeding. Exclude the local-only-* convention by name as well.
139
+ function isLocalOnlyName(filePath) {
140
+ const name = filePath.split(sep).pop() || '';
141
+ return name.startsWith('local-only-');
142
+ }
143
+
132
144
  function stripFrontmatter(content) {
133
145
  if (!content.startsWith('---\n')) return content;
134
146
  const end = content.indexOf('\n---\n', 4);
@@ -159,7 +171,7 @@ function buildSharedIndex(workspaceRoot) {
159
171
  const relToWC = relative(wcRoot, f).split(sep).join('/');
160
172
  if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
161
173
  if (isIgnored(relToWC, ignorePrefixes)) continue;
162
- if (gitIgnored.has(candidatePaths[i])) continue;
174
+ if (gitIgnored.has(candidatePaths[i]) || isLocalOnlyName(f)) continue;
163
175
  const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
164
176
  const { description } = describeAndPath(f, wcRoot);
165
177
  entries.push({ rel: relToWC, isLocked, description });
@@ -171,11 +183,10 @@ function buildSharedIndex(workspaceRoot) {
171
183
  return entries;
172
184
  }
173
185
 
174
- function renderSharedIndex(entries, generatedAt) {
186
+ function renderSharedIndex(entries) {
175
187
  const lines = [
176
188
  '---',
177
189
  'type: index',
178
- `generated: ${generatedAt}`,
179
190
  '---',
180
191
  '',
181
192
  '# workspace-context — index',
@@ -293,16 +304,19 @@ export function extractCanonicalVariants({ name, rawContent }) {
293
304
  }
294
305
 
295
306
  /**
296
- * Read `workspace.canonicalBudgetBytes` from `workspace.json`.
307
+ * Read `workspace.canonicalBudgetBytes` from `workspace.json`. The budget is
308
+ * opt-in — the whole always-loaded set is measured by `alwaysLoadedBudgetBytes`
309
+ * instead, so a canonical-only ceiling is something a workspace opts into.
297
310
  * Returns:
298
- * - The integer value when set to a non-negative integer.
299
- * - 0 when set to 0 or a negative number (treated as disabled).
300
- * - DEFAULT_CANONICAL_BUDGET when the field is absent or workspace.json is missing.
311
+ * - 0 when the field is absent or `null`, or workspace.json is missing (off).
312
+ * - 0 when set to 0 or a negative number (treated as off).
313
+ * - The integer value when set to a positive number.
314
+ * - DEFAULT_CANONICAL_BUDGET when set to a non-number (invalid, rejected as before).
301
315
  * - DEFAULT_CANONICAL_BUDGET (with a stderr warning) when workspace.json fails to parse.
302
316
  */
303
317
  export function readWorkspaceBudget(workspaceRoot) {
304
318
  const path = join(workspaceRoot, 'workspace.json');
305
- if (!existsSync(path)) return DEFAULT_CANONICAL_BUDGET;
319
+ if (!existsSync(path)) return 0;
306
320
  let parsed;
307
321
  try {
308
322
  parsed = JSON.parse(readFileSync(path, 'utf-8'));
@@ -311,9 +325,10 @@ export function readWorkspaceBudget(workspaceRoot) {
311
325
  return DEFAULT_CANONICAL_BUDGET;
312
326
  }
313
327
  const ws = parsed && typeof parsed === 'object' ? parsed.workspace : null;
314
- if (!ws || typeof ws !== 'object') return DEFAULT_CANONICAL_BUDGET;
315
- if (!('canonicalBudgetBytes' in ws)) return DEFAULT_CANONICAL_BUDGET;
328
+ if (!ws || typeof ws !== 'object') return 0;
329
+ if (!('canonicalBudgetBytes' in ws)) return 0;
316
330
  const v = ws.canonicalBudgetBytes;
331
+ if (v === null) return 0;
317
332
  if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_CANONICAL_BUDGET;
318
333
  if (v <= 0) return 0;
319
334
  return Math.floor(v);
@@ -351,7 +366,8 @@ export function renderCanonicalBody(resolvedItems) {
351
366
  * 4. Keep stage-3 resolution; status `over-budget`, overBy populated.
352
367
  *
353
368
  * Special cases:
354
- * - budgetBytes <= 0: stage 1 always wins, selection.budgetBytes = null.
369
+ * - budgetBytes <= 0 (off — absent, null, 0, or negative): stage 1 always
370
+ * wins, selection.budgetBytes = null.
355
371
  * - No reference items present and stage 1 fails: status `over-budget`,
356
372
  * no transformation possible. Stderr warning is emitted.
357
373
  */
@@ -372,7 +388,7 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
372
388
  return { name: item.name, priority: item.priority, content: item.full };
373
389
  });
374
390
 
375
- // Disabled-budget path.
391
+ // No-budget path (off because unset, or explicitly disabled).
376
392
  if (!Number.isFinite(budgetBytes) || budgetBytes <= 0) {
377
393
  const resolved = resolveAt(1);
378
394
  return {
@@ -470,7 +486,16 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
470
486
  function buildCanonical(workspaceRoot) {
471
487
  const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
472
488
  if (!existsSync(lockedDir)) return [];
473
- const files = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
489
+ const candidates = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
490
+ // canonical.md is committed and loaded verbatim into every session prompt, so a
491
+ // gitignored file under shared/locked/ must never reach it. The index builder applies
492
+ // this same filter; without it here, a local-only-*.md dropped into this directory is
493
+ // published to the repo and broadcast to every session.
494
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
495
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
496
+ const files = candidates.filter(
497
+ (f, i) => !gitIgnored.has(candidatePaths[i]) && !isLocalOnlyName(f),
498
+ );
474
499
  const items = [];
475
500
  for (const f of files) {
476
501
  const name = f.split(sep).pop().replace(/\.md$/, '');
@@ -501,9 +526,9 @@ function summarizeSelection(selection) {
501
526
  return `over budget by ${selection.overBy} bytes`;
502
527
  }
503
528
 
504
- function renderCanonical(resolvedItems, selection, generatedAt) {
529
+ function renderCanonical(resolvedItems, selection) {
505
530
  const showBudget = selection && selection.budgetBytes !== null && selection.budgetBytes !== undefined;
506
- const fmLines = ['---', 'type: canonical', `generated: ${generatedAt}`];
531
+ const fmLines = ['---', 'type: canonical'];
507
532
  if (showBudget) {
508
533
  fmLines.push(`budget: ${selection.budgetBytes}`);
509
534
  fmLines.push(`status: ${selection.status}`);
@@ -520,6 +545,8 @@ function renderCanonical(resolvedItems, selection, generatedAt) {
520
545
  lines.push(
521
546
  `> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
522
547
  );
548
+ } else if (selection) {
549
+ lines.push(`> Budget: off; current: ${selection.currentBytes} bytes.`);
523
550
  }
524
551
  lines.push('');
525
552
 
@@ -556,11 +583,10 @@ function buildTeamMemberIndex(workspaceRoot, user) {
556
583
  return entries;
557
584
  }
558
585
 
559
- function renderTeamMemberIndex(user, entries, generatedAt) {
586
+ function renderTeamMemberIndex(user, entries) {
560
587
  const lines = [
561
588
  '---',
562
589
  'type: index',
563
- `generated: ${generatedAt}`,
564
590
  '---',
565
591
  '',
566
592
  `# ${user}'s context`,
@@ -590,6 +616,23 @@ function listTeamMembers(workspaceRoot) {
590
616
 
591
617
  // ---------- orchestration ----------
592
618
 
619
+ // Artifacts no longer carry a `generated:` line — it changed on every build,
620
+ // nothing read it, and it made every long-lived branch conflict on files whose
621
+ // content was identical (gh:132). This filter stays so a checkout still holding
622
+ // a pre-fix artifact compares clean on body rather than reporting false staleness.
623
+ // An artifact git is configured to ignore is regenerated per machine, so its
624
+ // absence is expected rather than a failure. Falls back to treating the file
625
+ // as tracked when git is unavailable, which keeps the stricter behaviour.
626
+ function isUntrackedArtifact(root, absPath) {
627
+ const rel = relative(root, absPath).split(sep).join('/');
628
+ const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', rel], { encoding: 'utf-8' });
629
+ return r.status === 0;
630
+ }
631
+
632
+ function workspaceRootOf(root) {
633
+ return resolve(root);
634
+ }
635
+
593
636
  function fingerprint(content) {
594
637
  return content
595
638
  .split('\n')
@@ -597,7 +640,7 @@ function fingerprint(content) {
597
640
  .join('\n');
598
641
  }
599
642
 
600
- function regenerateAll(workspaceRoot, generatedAt) {
643
+ function regenerateAll(workspaceRoot) {
601
644
  const wcRoot = join(workspaceRoot, WC_DIR);
602
645
  if (!existsSync(wcRoot)) return [];
603
646
 
@@ -606,7 +649,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
606
649
  out.push({
607
650
  path: join(wcRoot, INDEX_FILENAME),
608
651
  label: 'index.md',
609
- content: renderSharedIndex(sharedEntries, generatedAt) + '\n',
652
+ content: renderSharedIndex(sharedEntries) + '\n',
610
653
  });
611
654
 
612
655
  const canonicalItems = buildCanonical(workspaceRoot);
@@ -617,7 +660,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
617
660
  out.push({
618
661
  path: join(wcRoot, CANONICAL_FILENAME),
619
662
  label: 'canonical.md',
620
- content: renderCanonical(resolvedItems, selection, generatedAt) + '\n',
663
+ content: renderCanonical(resolvedItems, selection) + '\n',
621
664
  selection,
622
665
  });
623
666
 
@@ -626,7 +669,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
626
669
  out.push({
627
670
  path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
628
671
  label: `team-member/${user}/index.md`,
629
- content: renderTeamMemberIndex(user, entries, generatedAt),
672
+ content: renderTeamMemberIndex(user, entries),
630
673
  });
631
674
  }
632
675
 
@@ -640,7 +683,8 @@ function regenerateAll(workspaceRoot, generatedAt) {
640
683
  * 0 — all artifacts current and canonical body is within budget.
641
684
  * 1 — at least one artifact is missing or stale on disk. Run `--write`.
642
685
  * 2 — artifacts are current but canonical body exceeds budget after
643
- * trimming and stubbing eligible reference files. Triage via
686
+ * trimming and stubbing eligible reference files (only reachable when
687
+ * a budget is set — off means nothing can exceed it). Triage via
644
688
  * `/maintenance cleanup`. If both stale and over-budget, exit 1.
645
689
  *
646
690
  * `--write` always exits 0 on successful regeneration; over-budget is
@@ -648,14 +692,22 @@ function regenerateAll(workspaceRoot, generatedAt) {
648
692
  */
649
693
  function main() {
650
694
  const args = parseArgs(process.argv);
651
- const generatedAt = new Date().toISOString();
652
- const artifacts = regenerateAll(args.root, generatedAt);
695
+ const artifacts = regenerateAll(args.root);
653
696
 
654
697
  if (args.mode === 'check') {
655
698
  const stale = [];
656
699
  const missing = [];
700
+ const regenerable = [];
657
701
  for (const a of artifacts) {
658
- if (!existsSync(a.path)) { missing.push(a.label); continue; }
702
+ if (!existsSync(a.path)) {
703
+ // Per-user indexes are gitignored (gh:132): absent is the normal state
704
+ // on a fresh checkout, not a staleness failure. Reporting them as
705
+ // missing would make --check, and therefore /maintenance and CI, fail
706
+ // on every clone. They are regenerated on demand instead.
707
+ if (isUntrackedArtifact(workspaceRootOf(args.root), a.path)) regenerable.push(a.label);
708
+ else missing.push(a.label);
709
+ continue;
710
+ }
659
711
  const onDisk = readFileSync(a.path, 'utf-8');
660
712
  if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
661
713
  }
@@ -674,6 +726,7 @@ function main() {
674
726
  if (missing.length === 0 && stale.length === 0) {
675
727
  const overBudget = sel && sel.status === 'over-budget';
676
728
  const payload = { status: 'current', missing: [], stale: [] };
729
+ if (regenerable.length) payload.regenerable = regenerable;
677
730
  if (canonicalBlock) payload.canonical = canonicalBlock;
678
731
  payload.artifacts = artifacts.length;
679
732
  process.stdout.write(JSON.stringify(payload) + '\n');
@@ -681,6 +734,7 @@ function main() {
681
734
  }
682
735
 
683
736
  const payload = { status: 'stale', missing, stale };
737
+ if (regenerable.length) payload.regenerable = regenerable;
684
738
  if (canonicalBlock) payload.canonical = canonicalBlock;
685
739
  process.stdout.write(JSON.stringify(payload) + '\n');
686
740
  process.exit(1);
@@ -709,4 +763,6 @@ export {
709
763
  fingerprint,
710
764
  readDescription,
711
765
  stripFrontmatter,
766
+ gitIgnoredPaths,
767
+ isLocalOnlyName,
712
768
  };
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ // The chat record: one file per long-lived chat, holding what that chat owns.
3
+ //
4
+ // The framework assumed one task spanning many chats and built a folder and a
5
+ // tracker file around it. Practice is one chat spanning many tasks (gh:132).
6
+ // The durable per-chat state is small — a declared scope, the concerns it
7
+ // subscribes to, and the tasks currently open under it — and it is
8
+ // machine-local, because chats are.
9
+ //
10
+ // Keyed on sessionId, filed under the chat name. Claude Code lets a session be
11
+ // renamed at any time, so a record keyed only by name is orphaned by a rename
12
+ // and a record keyed only by id is unreadable. The session registry carries
13
+ // both, so the record carries both: the filename stays legible, and
14
+ // reconcile() renames it when the chat's name changes.
15
+ //
16
+ // Lives in workspace-scratchpad/ because it is machine-local and regenerable —
17
+ // losing it costs one re-declaration of scope, not the work.
18
+ //
19
+ // Usage:
20
+ // node chat-record.mjs --root <dir> --list
21
+ // node chat-record.mjs --root <dir> --read <chat-name>
22
+ // node chat-record.mjs --root <dir> --reconcile --session-id <id> --name <n>
23
+ // node chat-record.mjs --root <dir> --whoami
24
+ // node chat-record.mjs --root <dir> --add-task --chat <n> --work-item <id> --branch <b> [--repo <r>]
25
+ // node chat-record.mjs --root <dir> --remove-task --chat <n> --work-item <id> [--repo <r>]
26
+ //
27
+ // --add-task / --remove-task report `action`: "added" | "updated" for
28
+ // --add-task, "removed" | "unchanged" for --remove-task. They also keep the
29
+ // older `updated` boolean / `removed` count fields — `updated` means "an
30
+ // existing entry was replaced", not "anything changed" — but new callers
31
+ // should read `action`. --whoami prints this chat's record name by matching
32
+ // $CLAUDE_CODE_SESSION_ID against the records' sessionId, nothing and exit
33
+ // 1 when there is no match: the `Chat record:` hook line can be missing
34
+ // after context compaction, and this is the recovery path.
35
+
36
+ import {
37
+ readFileSync, writeFileSync, existsSync, mkdirSync,
38
+ readdirSync, renameSync, rmSync, realpathSync,
39
+ } from 'node:fs';
40
+ import { join, resolve } from 'node:path';
41
+ import { homedir } from 'node:os';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ function isMainModule(metaUrl) {
45
+ if (!process.argv[1]) return false;
46
+ try {
47
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
48
+ } catch { return false; }
49
+ }
50
+
51
+ const CHATS_DIR = join('workspace-scratchpad', 'chats');
52
+
53
+ function chatsDir(root) {
54
+ return join(resolve(root), CHATS_DIR);
55
+ }
56
+
57
+ function recordPath(root, chatName) {
58
+ return join(chatsDir(root), `${chatName}.json`);
59
+ }
60
+
61
+ // The chat drawer: in-progress designs, braindumps and research that should not
62
+ // reach other chats until promoted. This is why local-only-* piles up at
63
+ // workspace roots today — capture skills invoked from the launcher have nowhere
64
+ // chat-scoped to write.
65
+ function drawerPath(root, chatName) {
66
+ return join(chatsDir(root), chatName);
67
+ }
68
+
69
+ function emptyRecord(chatName, sessionId) {
70
+ return {
71
+ chat: chatName,
72
+ sessionId,
73
+ scope: { epic: null, labels: [], paths: [] },
74
+ concerns: [],
75
+ tasks: [],
76
+ };
77
+ }
78
+
79
+ function readRecord(root, chatName) {
80
+ const p = recordPath(root, chatName);
81
+ if (!existsSync(p)) return null;
82
+ try {
83
+ return JSON.parse(readFileSync(p, 'utf-8'));
84
+ } catch {
85
+ // A corrupt record is machine-local and regenerable. Refusing to start a
86
+ // session over it would be worse than losing a scope declaration.
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function writeRecord(root, record) {
92
+ if (!record || !record.chat) throw new Error('writeRecord: record.chat is required');
93
+ const dir = chatsDir(root);
94
+ mkdirSync(dir, { recursive: true });
95
+ writeFileSync(recordPath(root, record.chat), `${JSON.stringify(record, null, 2)}\n`);
96
+ return recordPath(root, record.chat);
97
+ }
98
+
99
+ function listRecords(root) {
100
+ const dir = chatsDir(root);
101
+ if (!existsSync(dir)) return [];
102
+ return readdirSync(dir)
103
+ .filter((n) => n.endsWith('.json'))
104
+ .map((n) => readRecord(root, n.slice(0, -5)))
105
+ .filter(Boolean);
106
+ }
107
+
108
+ /**
109
+ * Bring the records on disk into line with the live session.
110
+ *
111
+ * - A record whose `sessionId` matches but whose filename does not is renamed:
112
+ * the chat was renamed, and the state follows it.
113
+ * - A record with no matching live session is stale. When `liveSessionIds` is
114
+ * supplied it is pruned; when it is not, pruning is skipped entirely rather
115
+ * than guessed at — deleting state on incomplete information is worse than
116
+ * leaving it.
117
+ */
118
+ function reconcile(root, { sessionId, name, liveSessionIds = null } = {}) {
119
+ if (!sessionId || !name) throw new Error('reconcile: sessionId and name are required');
120
+ const result = { renamed: null, created: false, pruned: [] };
121
+
122
+ const existing = listRecords(root);
123
+ const mine = existing.find((r) => r.sessionId === sessionId);
124
+
125
+ if (mine && mine.chat !== name) {
126
+ const oldName = mine.chat;
127
+ const from = recordPath(root, oldName);
128
+ const to = recordPath(root, name);
129
+
130
+ mine.chat = name;
131
+ writeRecord(root, mine);
132
+ if (existsSync(from) && from !== to) rmSync(from);
133
+
134
+ // The drawer travels with the record. A rename that leaves captures behind
135
+ // under the old name strands them somewhere nothing looks.
136
+ const drawerFrom = drawerPath(root, oldName);
137
+ const drawerTo = drawerPath(root, name);
138
+ if (existsSync(drawerFrom) && !existsSync(drawerTo)) renameSync(drawerFrom, drawerTo);
139
+
140
+ result.renamed = { from: oldName, to: name };
141
+ } else if (!mine) {
142
+ writeRecord(root, emptyRecord(name, sessionId));
143
+ result.created = true;
144
+ }
145
+
146
+ if (Array.isArray(liveSessionIds)) {
147
+ for (const r of listRecords(root)) {
148
+ if (r.sessionId === sessionId) continue;
149
+ if (!liveSessionIds.includes(r.sessionId)) {
150
+ rmSync(recordPath(root, r.chat), { force: true });
151
+ result.pruned.push(r.chat);
152
+ }
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+
158
+
159
+ // Claude Code's session registry: one file per running process, carrying both
160
+ // the stable sessionId and the current, renameable name.
161
+ //
162
+ // Note what this is NOT: a list of chats that exist. It holds only processes
163
+ // running right now, so a chat the operator closed looks exactly like one that
164
+ // never existed. That is why reconcile() will not prune from it unless a caller
165
+ // explicitly says to.
166
+ function readSessionRegistry(homeDir = homedir()) {
167
+ const dir = join(homeDir, '.claude', 'sessions');
168
+ if (!existsSync(dir)) return [];
169
+ const out = [];
170
+ for (const name of readdirSync(dir)) {
171
+ if (!name.endsWith('.json')) continue;
172
+ try {
173
+ const rec = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
174
+ if (rec && rec.sessionId) out.push(rec);
175
+ } catch { /* a half-written registry file is not our problem to fix */ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * Decide the name a chat's record should live under. The registry's name
182
+ * wins when it has one; otherwise an existing record for the same
183
+ * sessionId keeps its own name — a chat the registry has not named yet
184
+ * must not have its named record relabeled with the raw UUID. A genuinely
185
+ * new, unnamed chat falls back to the id so a record still exists.
186
+ */
187
+ function resolveChatName(root, { sessionId, registryName = null } = {}) {
188
+ if (registryName) return registryName;
189
+ const existing = listRecords(root).find((r) => r.sessionId === sessionId);
190
+ return existing ? existing.chat : sessionId;
191
+ }
192
+
193
+
194
+ // A task is a tracker issue plus a branch plus the repo it lands in. It is
195
+ // created on demand and disappears when it merges — nothing about it is
196
+ // durable except the issue and the commits, so the record holds only the
197
+ // pointer, never a copy of the issue.
198
+ //
199
+ // Identity is workItem + repo: the same issue can legitimately be open against
200
+ // two repos in a multi-repo task, and re-recording the same one must update
201
+ // rather than duplicate. /start-work is not always run exactly once.
202
+ function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
203
+ if (!workItem) throw new Error('addTask: workItem is required');
204
+ if (!branch) throw new Error('addTask: branch is required');
205
+ const rec = readRecord(root, chatName);
206
+ if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
207
+ const i = rec.tasks.findIndex((t) => t.workItem === workItem && (t.repo ?? null) === repo);
208
+ const task = { workItem, branch, repo };
209
+ if (i >= 0) rec.tasks[i] = task;
210
+ else rec.tasks.push(task);
211
+ writeRecord(root, rec);
212
+ return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
213
+ }
214
+
215
+ function removeTask(root, chatName, { workItem, repo = null } = {}) {
216
+ if (!workItem) throw new Error('removeTask: workItem is required');
217
+ const rec = readRecord(root, chatName);
218
+ if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
219
+ const before = rec.tasks.length;
220
+ rec.tasks = rec.tasks.filter((t) => !(t.workItem === workItem && (t.repo ?? null) === repo));
221
+ writeRecord(root, rec);
222
+ return { record: rec, action: before - rec.tasks.length > 0 ? 'removed' : 'unchanged', removed: before - rec.tasks.length };
223
+ }
224
+
225
+ // The record name for the chat running now. The SessionStart hook injects
226
+ // a `Chat record:` line, but compaction can drop it; the sessionId the
227
+ // hook keyed on is stable, so matching it against the records recovers the
228
+ // name without guessing. Null when the env var is unset or nothing matches.
229
+ function whoami(root, { env = process.env } = {}) {
230
+ const sid = env.CLAUDE_CODE_SESSION_ID;
231
+ if (!sid) return null;
232
+ const mine = listRecords(root).find((r) => r.sessionId === sid);
233
+ return mine ? mine.chat : null;
234
+ }
235
+
236
+ // Scope is what a chat declares it owns. The Aug 26 coordination burst had
237
+ // sessions declaring this by hand in chat messages; recording it makes it
238
+ // answerable without asking.
239
+ function setScope(root, chatName, { epic = null, labels = [], paths = [] } = {}) {
240
+ const rec = readRecord(root, chatName);
241
+ if (!rec) throw new Error(`setScope: no chat record for "${chatName}"`);
242
+ rec.scope = { epic, labels, paths };
243
+ writeRecord(root, rec);
244
+ return rec;
245
+ }
246
+
247
+ function parseArgs(argv) {
248
+ const args = { root: '.', mode: null, chat: null, sessionId: null, name: null, workItem: null, branch: null, repo: null };
249
+ const rest = argv.slice(2);
250
+ for (let i = 0; i < rest.length; i += 1) {
251
+ const a = rest[i];
252
+ if (a === '--root') { args.root = rest[++i]; continue; }
253
+ if (a === '--list') { args.mode = 'list'; continue; }
254
+ if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
255
+ if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
256
+ if (a === '--whoami') { args.mode = 'whoami'; continue; }
257
+ if (a === '--add-task') { args.mode = 'add-task'; continue; }
258
+ if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
259
+ if (a === '--chat') { args.chat = rest[++i]; continue; }
260
+ if (a === '--work-item') { args.workItem = rest[++i]; continue; }
261
+ if (a === '--branch') { args.branch = rest[++i]; continue; }
262
+ if (a === '--repo') { args.repo = rest[++i]; continue; }
263
+ if (a === '--session-id') { args.sessionId = rest[++i]; continue; }
264
+ if (a === '--name') { args.name = rest[++i]; continue; }
265
+ throw new Error(`unknown argument: ${a}`);
266
+ }
267
+ if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami is required');
268
+ if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
269
+ throw new Error('--reconcile requires --session-id and --name');
270
+ }
271
+ if (args.mode === 'add-task' && (!args.chat || !args.workItem || !args.branch)) {
272
+ throw new Error('--add-task requires --chat, --work-item and --branch');
273
+ }
274
+ if (args.mode === 'remove-task' && (!args.chat || !args.workItem)) {
275
+ throw new Error('--remove-task requires --chat and --work-item');
276
+ }
277
+ return args;
278
+ }
279
+
280
+ function main() {
281
+ const args = parseArgs(process.argv);
282
+ if (args.mode === 'whoami') {
283
+ // A bare name, not JSON: the caller wants something to put on a command
284
+ // line. No match prints nothing and exits 1 — the caller decides what
285
+ // an unidentified chat means.
286
+ const name = whoami(args.root);
287
+ if (name === null) process.exit(1);
288
+ process.stdout.write(`${name}\n`);
289
+ return;
290
+ }
291
+ let out;
292
+ if (args.mode === 'list') out = listRecords(args.root);
293
+ else if (args.mode === 'read') out = readRecord(args.root, args.chat);
294
+ else if (args.mode === 'add-task') {
295
+ out = addTask(args.root, args.chat, { workItem: args.workItem, branch: args.branch, repo: args.repo });
296
+ } else if (args.mode === 'remove-task') {
297
+ out = removeTask(args.root, args.chat, { workItem: args.workItem, repo: args.repo });
298
+ } else out = reconcile(args.root, { sessionId: args.sessionId, name: args.name });
299
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
300
+ }
301
+
302
+ if (isMainModule(import.meta.url)) {
303
+ try {
304
+ main();
305
+ } catch (err) {
306
+ process.stderr.write(`chat-record: ${err.message}\n`);
307
+ process.exit(2);
308
+ }
309
+ }
310
+
311
+ export {
312
+ recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
313
+ listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
314
+ addTask, removeTask, setScope, whoami, CHATS_DIR,
315
+ };