@ulysses-ai/create-workspace 0.18.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 (103) hide show
  1. package/lib/init.mjs +4 -1
  2. package/lib/payload.mjs +18 -1
  3. package/lib/payload.test.mjs +55 -0
  4. package/lib/scaffold.mjs +23 -6
  5. package/lib/scaffold.test.mjs +59 -0
  6. package/package.json +1 -1
  7. package/template/{.claude → _claude}/rules/forge-operations.md +6 -0
  8. package/template/{.claude → _claude}/rules/memory-guidance.md +4 -0
  9. package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
  10. package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +25 -14
  11. package/template/{.claude → _claude}/scripts/chat-record.mjs +37 -4
  12. package/template/{.claude → _claude}/scripts/context-footprint.mjs +139 -30
  13. package/template/{.claude → _claude}/scripts/forges/github.mjs +2 -1
  14. package/template/{.claude → _claude}/scripts/forges/interface.mjs +5 -4
  15. package/template/_claude/scripts/task-pr.mjs +447 -0
  16. package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
  17. package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
  18. package/template/{.claude → _claude}/skills/braindump/SKILL.md +1 -0
  19. package/template/{.claude → _claude}/skills/complete-work/SKILL.md +13 -69
  20. package/template/{.claude → _claude}/skills/context-placement/SKILL.md +8 -5
  21. package/template/{.claude → _claude}/skills/goal-driven-work/SKILL.md +1 -1
  22. package/template/{.claude → _claude}/skills/handoff/SKILL.md +1 -0
  23. package/template/{.claude → _claude}/skills/maintenance/SKILL.md +49 -17
  24. package/template/{.claude → _claude}/skills/release/SKILL.md +6 -2
  25. package/template/{.claude → _claude}/skills/start-work/SKILL.md +2 -2
  26. package/template/workspace.json.tmpl +1 -1
  27. /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
  28. /package/template/{.claude → _claude}/agents/implementer.md +0 -0
  29. /package/template/{.claude → _claude}/agents/researcher.md +0 -0
  30. /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
  31. /package/template/{.claude → _claude}/hooks/_utils.mjs +0 -0
  32. /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
  33. /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
  34. /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
  35. /package/template/{.claude → _claude}/hooks/repo-write-detection.mjs +0 -0
  36. /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
  37. /package/template/{.claude → _claude}/hooks/session-start.mjs +0 -0
  38. /package/template/{.claude → _claude}/hooks/subagent-start.mjs +0 -0
  39. /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
  40. /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
  41. /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
  42. /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
  43. /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
  44. /package/template/{.claude → _claude}/lib/session-frontmatter.mjs +0 -0
  45. /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
  46. /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
  47. /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
  48. /package/template/{.claude → _claude}/rules/coherent-revisions.md +0 -0
  49. /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
  50. /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
  51. /package/template/{.claude → _claude}/rules/git-conventions.md +0 -0
  52. /package/template/{.claude → _claude}/rules/goal-driven-work.md +0 -0
  53. /package/template/{.claude → _claude}/rules/honest-pushback.md +0 -0
  54. /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
  55. /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
  56. /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
  57. /package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +0 -0
  58. /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
  59. /package/template/{.claude → _claude}/rules/work-item-tracking.md +0 -0
  60. /package/template/{.claude → _claude}/rules/workspace-structure.md +0 -0
  61. /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
  62. /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
  63. /package/template/{.claude → _claude}/scripts/cleanup-work-session.mjs +0 -0
  64. /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
  65. /package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +0 -0
  66. /package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +0 -0
  67. /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
  68. /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
  69. /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
  70. /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
  71. /package/template/{.claude → _claude}/scripts/migrate-sessions.mjs +0 -0
  72. /package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +0 -0
  73. /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
  74. /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
  75. /package/template/{.claude → _claude}/scripts/task-worktree.mjs +0 -0
  76. /package/template/{.claude → _claude}/scripts/workspace-diagnostics.mjs +0 -0
  77. /package/template/{.claude → _claude}/settings.json +0 -0
  78. /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
  79. /package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +0 -0
  80. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
  81. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
  82. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
  83. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
  84. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
  85. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
  86. /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
  87. /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
  88. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
  89. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
  90. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
  91. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
  92. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
  93. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
  94. /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
  95. /package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +0 -0
  96. /package/template/{.claude → _claude}/skills/migrate-sessions/SKILL.md +0 -0
  97. /package/template/{.claude → _claude}/skills/pause-work/SKILL.md +0 -0
  98. /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
  99. /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
  100. /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
  101. /package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +0 -0
  102. /package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +0 -0
  103. /package/template/{.mcp.json → _mcp.json} +0 -0
package/lib/init.mjs CHANGED
@@ -20,7 +20,10 @@ export async function initWorkspace(targetDir) {
20
20
  const { toVersion, payloadDir } = stagePayload(targetDir, { action: 'init' });
21
21
  console.log(` Staged template payload (v${toVersion})`);
22
22
 
23
- // Install only the bootstrap skills needed to complete initialization
23
+ // Install only the bootstrap skills needed to complete initialization.
24
+ // The payload keeps live names (.claude/, .mcp.json) — stagePayload()
25
+ // maps them back from the template's inert names so the staged layout
26
+ // stays readable by skills already installed in the workspace.
24
27
  const bootstrapSkills = ['workspace-init', 'workspace-update'];
25
28
  const payloadSkills = join(payloadDir, '.claude', 'skills');
26
29
  const targetSkills = join(targetDir, '.claude', 'skills');
package/lib/payload.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // lib/payload.mjs
2
- import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
2
+ import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
@@ -22,6 +22,23 @@ export function stagePayload(targetDir, { action, fromVersion = null }) {
22
22
  // Copy template to payload directory
23
23
  cpSync(TEMPLATE_DIR, payloadDir, { recursive: true });
24
24
 
25
+ // The template stores .claude/ and .mcp.json under the inert names
26
+ // _claude/ and _mcp.json (Claude Code protects the live names from
27
+ // headless edits). The staged payload is an on-disk format shared with
28
+ // the /workspace-update skill ALREADY INSTALLED in the workspace, which
29
+ // reads .workspace-update/.claude/... — so it must keep the live names
30
+ // exactly as older package versions staged them. _gitignore stays inert:
31
+ // the skills have always merged it under that name.
32
+ for (const [from, to] of [
33
+ ['_claude', '.claude'],
34
+ ['_mcp.json', '.mcp.json'],
35
+ ]) {
36
+ const src = join(payloadDir, from);
37
+ if (existsSync(src)) {
38
+ renameSync(src, join(payloadDir, to));
39
+ }
40
+ }
41
+
25
42
  // Write manifest
26
43
  const manifest = {
27
44
  action,
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for payload.mjs
3
+ // Run: node lib/payload.test.mjs
4
+ //
5
+ // The staged .workspace-update/ payload is an on-disk format shared with
6
+ // the /workspace-update skill already installed in existing workspaces,
7
+ // which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
8
+ // These tests pin that layout even though template/ itself stores the
9
+ // protected paths under the inert names _claude/ and _mcp.json.
10
+ import { stagePayload, cleanPayload } from './payload.mjs';
11
+ import { mkdtempSync, rmSync, existsSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { tmpdir } from 'os';
14
+
15
+ let failed = 0;
16
+ let passed = 0;
17
+ function check(label, ok) {
18
+ if (ok) { passed++; } else {
19
+ failed++;
20
+ console.error(` FAIL: ${label}`);
21
+ }
22
+ }
23
+
24
+ const root = mkdtempSync(join(tmpdir(), 'payload-test-'));
25
+
26
+ try {
27
+ const { payloadDir, toVersion } = stagePayload(root, { action: 'init' });
28
+
29
+ check('payload staged at .workspace-update/', existsSync(payloadDir));
30
+ check('manifest written', existsSync(join(payloadDir, '.manifest.json')));
31
+ check('returns the package version', typeof toVersion === 'string' && toVersion.length > 0);
32
+
33
+ // The payload keeps the live names older workspaces' skills expect
34
+ check('.claude/ staged under live name', existsSync(join(payloadDir, '.claude')));
35
+ check('.claude/skills/workspace-update/SKILL.md staged', existsSync(join(payloadDir, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
36
+ check('.claude/settings.json staged', existsSync(join(payloadDir, '.claude', 'settings.json')));
37
+ check('.mcp.json staged under live name', existsSync(join(payloadDir, '.mcp.json')));
38
+ check('_gitignore stays inert (skills merge it under that name)', existsSync(join(payloadDir, '_gitignore')));
39
+
40
+ // The template's inert names must not leak into the payload
41
+ check('no _claude/ in payload', !existsSync(join(payloadDir, '_claude')));
42
+ check('no _mcp.json in payload', !existsSync(join(payloadDir, '_mcp.json')));
43
+
44
+ // cleanPayload removes the staging directory
45
+ cleanPayload(root);
46
+ check('cleanPayload removes .workspace-update/', !existsSync(payloadDir));
47
+ } finally {
48
+ rmSync(root, { recursive: true, force: true });
49
+ }
50
+
51
+ if (failed > 0) {
52
+ console.error(`${failed} check(s) failed, ${passed} passed`);
53
+ process.exit(1);
54
+ }
55
+ console.log(`${passed} checks passed`);
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
1
+ import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, rmSync, statSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
@@ -26,11 +26,28 @@ export async function scaffold(answers) {
26
26
  // scripts and hooks first need them — we do NOT pre-create them here.
27
27
  mkdirSync(join(directory, 'workspace-context', 'locked'), { recursive: true });
28
28
 
29
- // Rename _gitignore to .gitignore
30
- const gitignoreSrc = join(directory, '_gitignore');
31
- const gitignoreDest = join(directory, '.gitignore');
32
- if (existsSync(gitignoreSrc)) {
33
- renameSync(gitignoreSrc, gitignoreDest);
29
+ // The template stores some files under inert names — _gitignore,
30
+ // _claude/, _mcp.json — because Claude Code treats .claude/ directories
31
+ // and .mcp.json files as protected paths and would block automated edits
32
+ // to the template. Install each under its live name in the target.
33
+ const inertNames = [
34
+ { from: '_gitignore', to: '.gitignore' },
35
+ { from: '_claude', to: '.claude' },
36
+ { from: '_mcp.json', to: '.mcp.json' },
37
+ ];
38
+ for (const { from, to } of inertNames) {
39
+ const src = join(directory, from);
40
+ const dest = join(directory, to);
41
+ if (!existsSync(src)) continue;
42
+ if (existsSync(dest) && statSync(src).isDirectory()) {
43
+ // Pre-populated target: merge into the existing directory — template
44
+ // files overwrite same-named ones, extra local files survive — then
45
+ // drop the inert copy.
46
+ cpSync(src, dest, { recursive: true });
47
+ rmSync(src, { recursive: true });
48
+ } else {
49
+ renameSync(src, dest);
50
+ }
34
51
  }
35
52
 
36
53
  // Process CLAUDE.md template
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // End-to-end test for scaffold.mjs
3
+ // Run: node lib/scaffold.test.mjs
4
+ //
5
+ // The template stores .gitignore, .claude/, and .mcp.json under the inert
6
+ // names _gitignore, _claude/, and _mcp.json (Claude Code protects the live
7
+ // names from headless edits). scaffold() must install every one under its
8
+ // live name and leave no inert leftovers behind.
9
+ import { scaffold } from './scaffold.mjs';
10
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+
14
+ let failed = 0;
15
+ let passed = 0;
16
+ function check(label, ok) {
17
+ if (ok) { passed++; } else {
18
+ failed++;
19
+ console.error(` FAIL: ${label}`);
20
+ }
21
+ }
22
+
23
+ const directory = mkdtempSync(join(tmpdir(), 'scaffold-test-'));
24
+
25
+ try {
26
+ await scaffold({
27
+ name: 'demo',
28
+ directory,
29
+ repos: [],
30
+ userName: 'tester',
31
+ activateRules: [],
32
+ });
33
+
34
+ // Live names installed
35
+ check('.claude/ directory installed', existsSync(join(directory, '.claude')));
36
+ check('.claude/settings.json installed', existsSync(join(directory, '.claude', 'settings.json')));
37
+ check('.claude/settings.local.json written', existsSync(join(directory, '.claude', 'settings.local.json')));
38
+ check('.claude/rules/ installed', existsSync(join(directory, '.claude', 'rules')));
39
+ check('.claude/rules/coherent-revisions.md installed', existsSync(join(directory, '.claude', 'rules', 'coherent-revisions.md')));
40
+ check('.mcp.json installed', existsSync(join(directory, '.mcp.json')));
41
+ check('.gitignore installed', existsSync(join(directory, '.gitignore')));
42
+
43
+ // .mcp.json is the template's payload, unmodified
44
+ const mcp = JSON.parse(readFileSync(join(directory, '.mcp.json'), 'utf8'));
45
+ check('.mcp.json parses with mcpServers object', typeof mcp.mcpServers === 'object' && mcp.mcpServers !== null);
46
+
47
+ // Inert names are gone
48
+ check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
49
+ check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
50
+ check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
51
+ } finally {
52
+ rmSync(directory, { recursive: true, force: true });
53
+ }
54
+
55
+ if (failed > 0) {
56
+ console.error(`${failed} check(s) failed, ${passed} passed`);
57
+ process.exit(1);
58
+ }
59
+ console.log(`${passed} checks passed`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.18.0-beta.0",
3
+ "version": "0.19.0-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,3 +1,9 @@
1
+ ---
2
+ paths:
3
+ - "**/.claude/skills/**"
4
+ - "**/.claude/scripts/**"
5
+ ---
6
+
1
7
  Activate this rule if the workspace creates PRs, watches CI runs, or interacts with releases from skills. Sibling to `work-item-tracking.md` (which covers issues); together they cover everything a workspace does against a code-hosting forge.
2
8
 
3
9
  # Forge Operations
@@ -32,6 +32,10 @@ not need to be in context while editing documentation.
32
32
  node .claude/scripts/context-footprint.mjs --root . --add <bytes> --as <destination>
33
33
  ```
34
34
 
35
+ A rule that applies only to certain files should carry `paths:` frontmatter — it costs nothing
36
+ until a matching file is touched — and the same script checks the live total against
37
+ `workspace.alwaysLoadedBudgetBytes`.
38
+
35
39
  ## The canonical test
36
40
 
37
41
  Canonical describes what *is* and what *to do*, never what *to think*.
@@ -1,3 +1,9 @@
1
+ ---
2
+ paths:
3
+ - "work-sessions/**"
4
+ - "**/session.md"
5
+ ---
6
+
1
7
  # Task List Mirroring
2
8
 
3
9
  The Claude Code `TodoWrite` checklist is a live mirror of the workspace lifecycle. The durable backing store is a `## Tasks` section in `session.md`, round-tripped by `.claude/scripts/sync-tasks.mjs`. This rule defines the contract.
@@ -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,6 +27,7 @@
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';
@@ -301,16 +304,19 @@ export function extractCanonicalVariants({ name, rawContent }) {
301
304
  }
302
305
 
303
306
  /**
304
- * 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.
305
310
  * Returns:
306
- * - The integer value when set to a non-negative integer.
307
- * - 0 when set to 0 or a negative number (treated as disabled).
308
- * - 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).
309
315
  * - DEFAULT_CANONICAL_BUDGET (with a stderr warning) when workspace.json fails to parse.
310
316
  */
311
317
  export function readWorkspaceBudget(workspaceRoot) {
312
318
  const path = join(workspaceRoot, 'workspace.json');
313
- if (!existsSync(path)) return DEFAULT_CANONICAL_BUDGET;
319
+ if (!existsSync(path)) return 0;
314
320
  let parsed;
315
321
  try {
316
322
  parsed = JSON.parse(readFileSync(path, 'utf-8'));
@@ -319,9 +325,10 @@ export function readWorkspaceBudget(workspaceRoot) {
319
325
  return DEFAULT_CANONICAL_BUDGET;
320
326
  }
321
327
  const ws = parsed && typeof parsed === 'object' ? parsed.workspace : null;
322
- if (!ws || typeof ws !== 'object') return DEFAULT_CANONICAL_BUDGET;
323
- if (!('canonicalBudgetBytes' in ws)) return DEFAULT_CANONICAL_BUDGET;
328
+ if (!ws || typeof ws !== 'object') return 0;
329
+ if (!('canonicalBudgetBytes' in ws)) return 0;
324
330
  const v = ws.canonicalBudgetBytes;
331
+ if (v === null) return 0;
325
332
  if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_CANONICAL_BUDGET;
326
333
  if (v <= 0) return 0;
327
334
  return Math.floor(v);
@@ -359,7 +366,8 @@ export function renderCanonicalBody(resolvedItems) {
359
366
  * 4. Keep stage-3 resolution; status `over-budget`, overBy populated.
360
367
  *
361
368
  * Special cases:
362
- * - 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.
363
371
  * - No reference items present and stage 1 fails: status `over-budget`,
364
372
  * no transformation possible. Stderr warning is emitted.
365
373
  */
@@ -380,7 +388,7 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
380
388
  return { name: item.name, priority: item.priority, content: item.full };
381
389
  });
382
390
 
383
- // Disabled-budget path.
391
+ // No-budget path (off because unset, or explicitly disabled).
384
392
  if (!Number.isFinite(budgetBytes) || budgetBytes <= 0) {
385
393
  const resolved = resolveAt(1);
386
394
  return {
@@ -537,6 +545,8 @@ function renderCanonical(resolvedItems, selection) {
537
545
  lines.push(
538
546
  `> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
539
547
  );
548
+ } else if (selection) {
549
+ lines.push(`> Budget: off; current: ${selection.currentBytes} bytes.`);
540
550
  }
541
551
  lines.push('');
542
552
 
@@ -673,7 +683,8 @@ function regenerateAll(workspaceRoot) {
673
683
  * 0 — all artifacts current and canonical body is within budget.
674
684
  * 1 — at least one artifact is missing or stale on disk. Run `--write`.
675
685
  * 2 — artifacts are current but canonical body exceeds budget after
676
- * 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
677
688
  * `/maintenance cleanup`. If both stale and over-budget, exit 1.
678
689
  *
679
690
  * `--write` always exits 0 on successful regeneration; over-budget is
@@ -20,6 +20,18 @@
20
20
  // node chat-record.mjs --root <dir> --list
21
21
  // node chat-record.mjs --root <dir> --read <chat-name>
22
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.
23
35
 
24
36
  import {
25
37
  readFileSync, writeFileSync, existsSync, mkdirSync,
@@ -197,7 +209,7 @@ function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
197
209
  if (i >= 0) rec.tasks[i] = task;
198
210
  else rec.tasks.push(task);
199
211
  writeRecord(root, rec);
200
- return { record: rec, updated: i >= 0 };
212
+ return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
201
213
  }
202
214
 
203
215
  function removeTask(root, chatName, { workItem, repo = null } = {}) {
@@ -207,7 +219,18 @@ function removeTask(root, chatName, { workItem, repo = null } = {}) {
207
219
  const before = rec.tasks.length;
208
220
  rec.tasks = rec.tasks.filter((t) => !(t.workItem === workItem && (t.repo ?? null) === repo));
209
221
  writeRecord(root, rec);
210
- return { record: rec, removed: before - rec.tasks.length };
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;
211
234
  }
212
235
 
213
236
  // Scope is what a chat declares it owns. The Aug 26 coordination burst had
@@ -230,6 +253,7 @@ function parseArgs(argv) {
230
253
  if (a === '--list') { args.mode = 'list'; continue; }
231
254
  if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
232
255
  if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
256
+ if (a === '--whoami') { args.mode = 'whoami'; continue; }
233
257
  if (a === '--add-task') { args.mode = 'add-task'; continue; }
234
258
  if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
235
259
  if (a === '--chat') { args.chat = rest[++i]; continue; }
@@ -240,7 +264,7 @@ function parseArgs(argv) {
240
264
  if (a === '--name') { args.name = rest[++i]; continue; }
241
265
  throw new Error(`unknown argument: ${a}`);
242
266
  }
243
- if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile is required');
267
+ if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami is required');
244
268
  if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
245
269
  throw new Error('--reconcile requires --session-id and --name');
246
270
  }
@@ -255,6 +279,15 @@ function parseArgs(argv) {
255
279
 
256
280
  function main() {
257
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
+ }
258
291
  let out;
259
292
  if (args.mode === 'list') out = listRecords(args.root);
260
293
  else if (args.mode === 'read') out = readRecord(args.root, args.chat);
@@ -278,5 +311,5 @@ if (isMainModule(import.meta.url)) {
278
311
  export {
279
312
  recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
280
313
  listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
281
- addTask, removeTask, setScope, CHATS_DIR,
314
+ addTask, removeTask, setScope, whoami, CHATS_DIR,
282
315
  };