@ulysses-ai/create-workspace 0.16.0-beta.1 → 0.18.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 (53) hide show
  1. package/README.md +5 -5
  2. package/lib/init.mjs +19 -0
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/_utils.mjs +1 -1
  5. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  6. package/template/.claude/hooks/session-end.mjs +68 -2
  7. package/template/.claude/hooks/session-start.mjs +35 -1
  8. package/template/.claude/hooks/subagent-start.mjs +89 -22
  9. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  10. package/template/.claude/rules/coherent-revisions.md +1 -1
  11. package/template/.claude/rules/config-review.md.skip +29 -0
  12. package/template/.claude/rules/forge-operations.md +51 -0
  13. package/template/.claude/rules/git-conventions.md +16 -11
  14. package/template/.claude/rules/goal-driven-work.md +8 -403
  15. package/template/.claude/rules/honest-pushback.md +37 -37
  16. package/template/.claude/rules/memory-guidance.md +43 -90
  17. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  18. package/template/.claude/rules/work-item-tracking.md +30 -72
  19. package/template/.claude/rules/workspace-structure.md +49 -69
  20. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  21. package/template/.claude/scripts/chat-record.mjs +282 -0
  22. package/template/.claude/scripts/cleanup-work-session.mjs +363 -36
  23. package/template/.claude/scripts/context-footprint.mjs +282 -0
  24. package/template/.claude/scripts/forges/github.mjs +255 -0
  25. package/template/.claude/scripts/forges/gitlab.mjs +20 -0
  26. package/template/.claude/scripts/forges/interface.mjs +125 -0
  27. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  28. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  29. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  30. package/template/.claude/scripts/task-worktree.mjs +525 -0
  31. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  32. package/template/.claude/settings.json +5 -13
  33. package/template/.claude/skills/braindump/SKILL.md +11 -4
  34. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  35. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  36. package/template/.claude/skills/complete-work/SKILL.md +255 -215
  37. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  38. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  39. package/template/.claude/skills/handoff/SKILL.md +11 -4
  40. package/template/.claude/skills/maintenance/SKILL.md +39 -6
  41. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  42. package/template/.claude/skills/pause-work/SKILL.md +33 -8
  43. package/template/.claude/skills/release/SKILL.md +44 -108
  44. package/template/.claude/skills/start-work/SKILL.md +89 -7
  45. package/template/.claude/skills/workspace-init/SKILL.md +34 -0
  46. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  47. package/template/.claudeignore +3 -0
  48. package/template/CLAUDE.md.tmpl +20 -2
  49. package/template/CODEBASE.md.tmpl +13 -0
  50. package/template/_gitignore +9 -0
  51. package/template/repo-claude.md.tmpl +10 -0
  52. package/template/workspace.json.tmpl +5 -3
  53. package/template/.claude/hooks/worktree-create.mjs +0 -53
@@ -28,7 +28,7 @@
28
28
  // Stale wins over over-budget when both apply.
29
29
 
30
30
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
31
- import { join, relative, sep } from 'node:path';
31
+ import { join, relative, resolve, sep } from 'node:path';
32
32
  import { spawnSync } from 'node:child_process';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { parseSessionContent } from '../lib/session-frontmatter.mjs';
@@ -129,6 +129,15 @@ function gitIgnoredPaths(workspaceRoot, paths) {
129
129
  );
130
130
  }
131
131
 
132
+ // The git filter fails open: gitIgnoredPaths returns an empty set when git is missing,
133
+ // the directory is not a repository, or the subprocess errors. Canonical content is
134
+ // committed and broadcast to every session and subagent, so that path must not depend on a
135
+ // subprocess succeeding. Exclude the local-only-* convention by name as well.
136
+ function isLocalOnlyName(filePath) {
137
+ const name = filePath.split(sep).pop() || '';
138
+ return name.startsWith('local-only-');
139
+ }
140
+
132
141
  function stripFrontmatter(content) {
133
142
  if (!content.startsWith('---\n')) return content;
134
143
  const end = content.indexOf('\n---\n', 4);
@@ -159,7 +168,7 @@ function buildSharedIndex(workspaceRoot) {
159
168
  const relToWC = relative(wcRoot, f).split(sep).join('/');
160
169
  if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
161
170
  if (isIgnored(relToWC, ignorePrefixes)) continue;
162
- if (gitIgnored.has(candidatePaths[i])) continue;
171
+ if (gitIgnored.has(candidatePaths[i]) || isLocalOnlyName(f)) continue;
163
172
  const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
164
173
  const { description } = describeAndPath(f, wcRoot);
165
174
  entries.push({ rel: relToWC, isLocked, description });
@@ -171,11 +180,10 @@ function buildSharedIndex(workspaceRoot) {
171
180
  return entries;
172
181
  }
173
182
 
174
- function renderSharedIndex(entries, generatedAt) {
183
+ function renderSharedIndex(entries) {
175
184
  const lines = [
176
185
  '---',
177
186
  'type: index',
178
- `generated: ${generatedAt}`,
179
187
  '---',
180
188
  '',
181
189
  '# workspace-context — index',
@@ -470,7 +478,16 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
470
478
  function buildCanonical(workspaceRoot) {
471
479
  const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
472
480
  if (!existsSync(lockedDir)) return [];
473
- const files = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
481
+ const candidates = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
482
+ // canonical.md is committed and loaded verbatim into every session prompt, so a
483
+ // gitignored file under shared/locked/ must never reach it. The index builder applies
484
+ // this same filter; without it here, a local-only-*.md dropped into this directory is
485
+ // published to the repo and broadcast to every session.
486
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
487
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
488
+ const files = candidates.filter(
489
+ (f, i) => !gitIgnored.has(candidatePaths[i]) && !isLocalOnlyName(f),
490
+ );
474
491
  const items = [];
475
492
  for (const f of files) {
476
493
  const name = f.split(sep).pop().replace(/\.md$/, '');
@@ -501,9 +518,9 @@ function summarizeSelection(selection) {
501
518
  return `over budget by ${selection.overBy} bytes`;
502
519
  }
503
520
 
504
- function renderCanonical(resolvedItems, selection, generatedAt) {
521
+ function renderCanonical(resolvedItems, selection) {
505
522
  const showBudget = selection && selection.budgetBytes !== null && selection.budgetBytes !== undefined;
506
- const fmLines = ['---', 'type: canonical', `generated: ${generatedAt}`];
523
+ const fmLines = ['---', 'type: canonical'];
507
524
  if (showBudget) {
508
525
  fmLines.push(`budget: ${selection.budgetBytes}`);
509
526
  fmLines.push(`status: ${selection.status}`);
@@ -556,11 +573,10 @@ function buildTeamMemberIndex(workspaceRoot, user) {
556
573
  return entries;
557
574
  }
558
575
 
559
- function renderTeamMemberIndex(user, entries, generatedAt) {
576
+ function renderTeamMemberIndex(user, entries) {
560
577
  const lines = [
561
578
  '---',
562
579
  'type: index',
563
- `generated: ${generatedAt}`,
564
580
  '---',
565
581
  '',
566
582
  `# ${user}'s context`,
@@ -590,6 +606,23 @@ function listTeamMembers(workspaceRoot) {
590
606
 
591
607
  // ---------- orchestration ----------
592
608
 
609
+ // Artifacts no longer carry a `generated:` line — it changed on every build,
610
+ // nothing read it, and it made every long-lived branch conflict on files whose
611
+ // content was identical (gh:132). This filter stays so a checkout still holding
612
+ // a pre-fix artifact compares clean on body rather than reporting false staleness.
613
+ // An artifact git is configured to ignore is regenerated per machine, so its
614
+ // absence is expected rather than a failure. Falls back to treating the file
615
+ // as tracked when git is unavailable, which keeps the stricter behaviour.
616
+ function isUntrackedArtifact(root, absPath) {
617
+ const rel = relative(root, absPath).split(sep).join('/');
618
+ const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', rel], { encoding: 'utf-8' });
619
+ return r.status === 0;
620
+ }
621
+
622
+ function workspaceRootOf(root) {
623
+ return resolve(root);
624
+ }
625
+
593
626
  function fingerprint(content) {
594
627
  return content
595
628
  .split('\n')
@@ -597,7 +630,7 @@ function fingerprint(content) {
597
630
  .join('\n');
598
631
  }
599
632
 
600
- function regenerateAll(workspaceRoot, generatedAt) {
633
+ function regenerateAll(workspaceRoot) {
601
634
  const wcRoot = join(workspaceRoot, WC_DIR);
602
635
  if (!existsSync(wcRoot)) return [];
603
636
 
@@ -606,7 +639,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
606
639
  out.push({
607
640
  path: join(wcRoot, INDEX_FILENAME),
608
641
  label: 'index.md',
609
- content: renderSharedIndex(sharedEntries, generatedAt) + '\n',
642
+ content: renderSharedIndex(sharedEntries) + '\n',
610
643
  });
611
644
 
612
645
  const canonicalItems = buildCanonical(workspaceRoot);
@@ -617,7 +650,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
617
650
  out.push({
618
651
  path: join(wcRoot, CANONICAL_FILENAME),
619
652
  label: 'canonical.md',
620
- content: renderCanonical(resolvedItems, selection, generatedAt) + '\n',
653
+ content: renderCanonical(resolvedItems, selection) + '\n',
621
654
  selection,
622
655
  });
623
656
 
@@ -626,7 +659,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
626
659
  out.push({
627
660
  path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
628
661
  label: `team-member/${user}/index.md`,
629
- content: renderTeamMemberIndex(user, entries, generatedAt),
662
+ content: renderTeamMemberIndex(user, entries),
630
663
  });
631
664
  }
632
665
 
@@ -648,14 +681,22 @@ function regenerateAll(workspaceRoot, generatedAt) {
648
681
  */
649
682
  function main() {
650
683
  const args = parseArgs(process.argv);
651
- const generatedAt = new Date().toISOString();
652
- const artifacts = regenerateAll(args.root, generatedAt);
684
+ const artifacts = regenerateAll(args.root);
653
685
 
654
686
  if (args.mode === 'check') {
655
687
  const stale = [];
656
688
  const missing = [];
689
+ const regenerable = [];
657
690
  for (const a of artifacts) {
658
- if (!existsSync(a.path)) { missing.push(a.label); continue; }
691
+ if (!existsSync(a.path)) {
692
+ // Per-user indexes are gitignored (gh:132): absent is the normal state
693
+ // on a fresh checkout, not a staleness failure. Reporting them as
694
+ // missing would make --check, and therefore /maintenance and CI, fail
695
+ // on every clone. They are regenerated on demand instead.
696
+ if (isUntrackedArtifact(workspaceRootOf(args.root), a.path)) regenerable.push(a.label);
697
+ else missing.push(a.label);
698
+ continue;
699
+ }
659
700
  const onDisk = readFileSync(a.path, 'utf-8');
660
701
  if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
661
702
  }
@@ -674,6 +715,7 @@ function main() {
674
715
  if (missing.length === 0 && stale.length === 0) {
675
716
  const overBudget = sel && sel.status === 'over-budget';
676
717
  const payload = { status: 'current', missing: [], stale: [] };
718
+ if (regenerable.length) payload.regenerable = regenerable;
677
719
  if (canonicalBlock) payload.canonical = canonicalBlock;
678
720
  payload.artifacts = artifacts.length;
679
721
  process.stdout.write(JSON.stringify(payload) + '\n');
@@ -681,6 +723,7 @@ function main() {
681
723
  }
682
724
 
683
725
  const payload = { status: 'stale', missing, stale };
726
+ if (regenerable.length) payload.regenerable = regenerable;
684
727
  if (canonicalBlock) payload.canonical = canonicalBlock;
685
728
  process.stdout.write(JSON.stringify(payload) + '\n');
686
729
  process.exit(1);
@@ -709,4 +752,6 @@ export {
709
752
  fingerprint,
710
753
  readDescription,
711
754
  stripFrontmatter,
755
+ gitIgnoredPaths,
756
+ isLocalOnlyName,
712
757
  };
@@ -0,0 +1,282 @@
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
+
24
+ import {
25
+ readFileSync, writeFileSync, existsSync, mkdirSync,
26
+ readdirSync, renameSync, rmSync, realpathSync,
27
+ } from 'node:fs';
28
+ import { join, resolve } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ function isMainModule(metaUrl) {
33
+ if (!process.argv[1]) return false;
34
+ try {
35
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
36
+ } catch { return false; }
37
+ }
38
+
39
+ const CHATS_DIR = join('workspace-scratchpad', 'chats');
40
+
41
+ function chatsDir(root) {
42
+ return join(resolve(root), CHATS_DIR);
43
+ }
44
+
45
+ function recordPath(root, chatName) {
46
+ return join(chatsDir(root), `${chatName}.json`);
47
+ }
48
+
49
+ // The chat drawer: in-progress designs, braindumps and research that should not
50
+ // reach other chats until promoted. This is why local-only-* piles up at
51
+ // workspace roots today — capture skills invoked from the launcher have nowhere
52
+ // chat-scoped to write.
53
+ function drawerPath(root, chatName) {
54
+ return join(chatsDir(root), chatName);
55
+ }
56
+
57
+ function emptyRecord(chatName, sessionId) {
58
+ return {
59
+ chat: chatName,
60
+ sessionId,
61
+ scope: { epic: null, labels: [], paths: [] },
62
+ concerns: [],
63
+ tasks: [],
64
+ };
65
+ }
66
+
67
+ function readRecord(root, chatName) {
68
+ const p = recordPath(root, chatName);
69
+ if (!existsSync(p)) return null;
70
+ try {
71
+ return JSON.parse(readFileSync(p, 'utf-8'));
72
+ } catch {
73
+ // A corrupt record is machine-local and regenerable. Refusing to start a
74
+ // session over it would be worse than losing a scope declaration.
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function writeRecord(root, record) {
80
+ if (!record || !record.chat) throw new Error('writeRecord: record.chat is required');
81
+ const dir = chatsDir(root);
82
+ mkdirSync(dir, { recursive: true });
83
+ writeFileSync(recordPath(root, record.chat), `${JSON.stringify(record, null, 2)}\n`);
84
+ return recordPath(root, record.chat);
85
+ }
86
+
87
+ function listRecords(root) {
88
+ const dir = chatsDir(root);
89
+ if (!existsSync(dir)) return [];
90
+ return readdirSync(dir)
91
+ .filter((n) => n.endsWith('.json'))
92
+ .map((n) => readRecord(root, n.slice(0, -5)))
93
+ .filter(Boolean);
94
+ }
95
+
96
+ /**
97
+ * Bring the records on disk into line with the live session.
98
+ *
99
+ * - A record whose `sessionId` matches but whose filename does not is renamed:
100
+ * the chat was renamed, and the state follows it.
101
+ * - A record with no matching live session is stale. When `liveSessionIds` is
102
+ * supplied it is pruned; when it is not, pruning is skipped entirely rather
103
+ * than guessed at — deleting state on incomplete information is worse than
104
+ * leaving it.
105
+ */
106
+ function reconcile(root, { sessionId, name, liveSessionIds = null } = {}) {
107
+ if (!sessionId || !name) throw new Error('reconcile: sessionId and name are required');
108
+ const result = { renamed: null, created: false, pruned: [] };
109
+
110
+ const existing = listRecords(root);
111
+ const mine = existing.find((r) => r.sessionId === sessionId);
112
+
113
+ if (mine && mine.chat !== name) {
114
+ const oldName = mine.chat;
115
+ const from = recordPath(root, oldName);
116
+ const to = recordPath(root, name);
117
+
118
+ mine.chat = name;
119
+ writeRecord(root, mine);
120
+ if (existsSync(from) && from !== to) rmSync(from);
121
+
122
+ // The drawer travels with the record. A rename that leaves captures behind
123
+ // under the old name strands them somewhere nothing looks.
124
+ const drawerFrom = drawerPath(root, oldName);
125
+ const drawerTo = drawerPath(root, name);
126
+ if (existsSync(drawerFrom) && !existsSync(drawerTo)) renameSync(drawerFrom, drawerTo);
127
+
128
+ result.renamed = { from: oldName, to: name };
129
+ } else if (!mine) {
130
+ writeRecord(root, emptyRecord(name, sessionId));
131
+ result.created = true;
132
+ }
133
+
134
+ if (Array.isArray(liveSessionIds)) {
135
+ for (const r of listRecords(root)) {
136
+ if (r.sessionId === sessionId) continue;
137
+ if (!liveSessionIds.includes(r.sessionId)) {
138
+ rmSync(recordPath(root, r.chat), { force: true });
139
+ result.pruned.push(r.chat);
140
+ }
141
+ }
142
+ }
143
+ return result;
144
+ }
145
+
146
+
147
+ // Claude Code's session registry: one file per running process, carrying both
148
+ // the stable sessionId and the current, renameable name.
149
+ //
150
+ // Note what this is NOT: a list of chats that exist. It holds only processes
151
+ // running right now, so a chat the operator closed looks exactly like one that
152
+ // never existed. That is why reconcile() will not prune from it unless a caller
153
+ // explicitly says to.
154
+ function readSessionRegistry(homeDir = homedir()) {
155
+ const dir = join(homeDir, '.claude', 'sessions');
156
+ if (!existsSync(dir)) return [];
157
+ const out = [];
158
+ for (const name of readdirSync(dir)) {
159
+ if (!name.endsWith('.json')) continue;
160
+ try {
161
+ const rec = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
162
+ if (rec && rec.sessionId) out.push(rec);
163
+ } catch { /* a half-written registry file is not our problem to fix */ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Decide the name a chat's record should live under. The registry's name
170
+ * wins when it has one; otherwise an existing record for the same
171
+ * sessionId keeps its own name — a chat the registry has not named yet
172
+ * must not have its named record relabeled with the raw UUID. A genuinely
173
+ * new, unnamed chat falls back to the id so a record still exists.
174
+ */
175
+ function resolveChatName(root, { sessionId, registryName = null } = {}) {
176
+ if (registryName) return registryName;
177
+ const existing = listRecords(root).find((r) => r.sessionId === sessionId);
178
+ return existing ? existing.chat : sessionId;
179
+ }
180
+
181
+
182
+ // A task is a tracker issue plus a branch plus the repo it lands in. It is
183
+ // created on demand and disappears when it merges — nothing about it is
184
+ // durable except the issue and the commits, so the record holds only the
185
+ // pointer, never a copy of the issue.
186
+ //
187
+ // Identity is workItem + repo: the same issue can legitimately be open against
188
+ // two repos in a multi-repo task, and re-recording the same one must update
189
+ // rather than duplicate. /start-work is not always run exactly once.
190
+ function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
191
+ if (!workItem) throw new Error('addTask: workItem is required');
192
+ if (!branch) throw new Error('addTask: branch is required');
193
+ const rec = readRecord(root, chatName);
194
+ if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
195
+ const i = rec.tasks.findIndex((t) => t.workItem === workItem && (t.repo ?? null) === repo);
196
+ const task = { workItem, branch, repo };
197
+ if (i >= 0) rec.tasks[i] = task;
198
+ else rec.tasks.push(task);
199
+ writeRecord(root, rec);
200
+ return { record: rec, updated: i >= 0 };
201
+ }
202
+
203
+ function removeTask(root, chatName, { workItem, repo = null } = {}) {
204
+ if (!workItem) throw new Error('removeTask: workItem is required');
205
+ const rec = readRecord(root, chatName);
206
+ if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
207
+ const before = rec.tasks.length;
208
+ rec.tasks = rec.tasks.filter((t) => !(t.workItem === workItem && (t.repo ?? null) === repo));
209
+ writeRecord(root, rec);
210
+ return { record: rec, removed: before - rec.tasks.length };
211
+ }
212
+
213
+ // Scope is what a chat declares it owns. The Aug 26 coordination burst had
214
+ // sessions declaring this by hand in chat messages; recording it makes it
215
+ // answerable without asking.
216
+ function setScope(root, chatName, { epic = null, labels = [], paths = [] } = {}) {
217
+ const rec = readRecord(root, chatName);
218
+ if (!rec) throw new Error(`setScope: no chat record for "${chatName}"`);
219
+ rec.scope = { epic, labels, paths };
220
+ writeRecord(root, rec);
221
+ return rec;
222
+ }
223
+
224
+ function parseArgs(argv) {
225
+ const args = { root: '.', mode: null, chat: null, sessionId: null, name: null, workItem: null, branch: null, repo: null };
226
+ const rest = argv.slice(2);
227
+ for (let i = 0; i < rest.length; i += 1) {
228
+ const a = rest[i];
229
+ if (a === '--root') { args.root = rest[++i]; continue; }
230
+ if (a === '--list') { args.mode = 'list'; continue; }
231
+ if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
232
+ if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
233
+ if (a === '--add-task') { args.mode = 'add-task'; continue; }
234
+ if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
235
+ if (a === '--chat') { args.chat = rest[++i]; continue; }
236
+ if (a === '--work-item') { args.workItem = rest[++i]; continue; }
237
+ if (a === '--branch') { args.branch = rest[++i]; continue; }
238
+ if (a === '--repo') { args.repo = rest[++i]; continue; }
239
+ if (a === '--session-id') { args.sessionId = rest[++i]; continue; }
240
+ if (a === '--name') { args.name = rest[++i]; continue; }
241
+ throw new Error(`unknown argument: ${a}`);
242
+ }
243
+ if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile is required');
244
+ if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
245
+ throw new Error('--reconcile requires --session-id and --name');
246
+ }
247
+ if (args.mode === 'add-task' && (!args.chat || !args.workItem || !args.branch)) {
248
+ throw new Error('--add-task requires --chat, --work-item and --branch');
249
+ }
250
+ if (args.mode === 'remove-task' && (!args.chat || !args.workItem)) {
251
+ throw new Error('--remove-task requires --chat and --work-item');
252
+ }
253
+ return args;
254
+ }
255
+
256
+ function main() {
257
+ const args = parseArgs(process.argv);
258
+ let out;
259
+ if (args.mode === 'list') out = listRecords(args.root);
260
+ else if (args.mode === 'read') out = readRecord(args.root, args.chat);
261
+ else if (args.mode === 'add-task') {
262
+ out = addTask(args.root, args.chat, { workItem: args.workItem, branch: args.branch, repo: args.repo });
263
+ } else if (args.mode === 'remove-task') {
264
+ out = removeTask(args.root, args.chat, { workItem: args.workItem, repo: args.repo });
265
+ } else out = reconcile(args.root, { sessionId: args.sessionId, name: args.name });
266
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
267
+ }
268
+
269
+ if (isMainModule(import.meta.url)) {
270
+ try {
271
+ main();
272
+ } catch (err) {
273
+ process.stderr.write(`chat-record: ${err.message}\n`);
274
+ process.exit(2);
275
+ }
276
+ }
277
+
278
+ export {
279
+ recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
280
+ listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
281
+ addTask, removeTask, setScope, CHATS_DIR,
282
+ };