@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.
- package/README.md +5 -5
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/_utils.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +161 -64
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/hooks/session-start.mjs +35 -1
- package/template/.claude/hooks/subagent-start.mjs +89 -22
- package/template/.claude/lib/session-frontmatter.mjs +28 -0
- package/template/.claude/rules/coherent-revisions.md +1 -1
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +51 -0
- package/template/.claude/rules/git-conventions.md +16 -11
- package/template/.claude/rules/goal-driven-work.md +8 -403
- package/template/.claude/rules/honest-pushback.md +37 -37
- package/template/.claude/rules/memory-guidance.md +43 -90
- package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
- package/template/.claude/rules/work-item-tracking.md +30 -72
- package/template/.claude/rules/workspace-structure.md +49 -69
- package/template/.claude/scripts/build-workspace-context.mjs +61 -16
- package/template/.claude/scripts/chat-record.mjs +282 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +363 -36
- package/template/.claude/scripts/context-footprint.mjs +282 -0
- package/template/.claude/scripts/forges/github.mjs +255 -0
- package/template/.claude/scripts/forges/gitlab.mjs +20 -0
- package/template/.claude/scripts/forges/interface.mjs +125 -0
- package/template/.claude/scripts/generate-claude-local.mjs +21 -2
- package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/.claude/scripts/task-worktree.mjs +525 -0
- package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/braindump/SKILL.md +11 -4
- package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +255 -215
- package/template/.claude/skills/context-placement/SKILL.md +199 -0
- package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
- package/template/.claude/skills/handoff/SKILL.md +11 -4
- package/template/.claude/skills/maintenance/SKILL.md +39 -6
- package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/.claude/skills/pause-work/SKILL.md +33 -8
- package/template/.claude/skills/release/SKILL.md +44 -108
- package/template/.claude/skills/start-work/SKILL.md +89 -7
- package/template/.claude/skills/workspace-init/SKILL.md +34 -0
- package/template/.claude/skills/workspace-update/SKILL.md +4 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +20 -2
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/_gitignore +9 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +5 -3
- 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
|
|
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
|
|
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
|
|
521
|
+
function renderCanonical(resolvedItems, selection) {
|
|
505
522
|
const showBudget = selection && selection.budgetBytes !== null && selection.budgetBytes !== undefined;
|
|
506
|
-
const fmLines = ['---', 'type: canonical'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)) {
|
|
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
|
+
};
|