chapterhouse 0.3.13 → 0.3.14

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 (52) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +8 -155
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/cli.js +0 -30
  5. package/dist/config.js +0 -3
  6. package/dist/copilot/agent-event-bus.js +41 -0
  7. package/dist/copilot/agent-event-bus.test.js +23 -0
  8. package/dist/copilot/agents.js +4 -59
  9. package/dist/copilot/orchestrator.js +20 -39
  10. package/dist/copilot/orchestrator.test.js +73 -158
  11. package/dist/copilot/task-event-log.js +5 -5
  12. package/dist/copilot/task-event-log.test.js +68 -142
  13. package/dist/copilot/tools.js +9 -85
  14. package/dist/daemon.js +0 -22
  15. package/dist/store/db.js +2 -50
  16. package/dist/store/db.test.js +0 -45
  17. package/package.json +1 -3
  18. package/web/dist/assets/index-BlIWCM11.js +217 -0
  19. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  20. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  21. package/web/dist/index.html +2 -2
  22. package/dist/api/ralph.js +0 -153
  23. package/dist/api/ralph.test.js +0 -101
  24. package/dist/copilot/agents.squad.test.js +0 -72
  25. package/dist/copilot/hooks.js +0 -157
  26. package/dist/copilot/hooks.test.js +0 -315
  27. package/dist/copilot/squad-event-bus.js +0 -27
  28. package/dist/copilot/tools.squad.test.js +0 -168
  29. package/dist/squad/charter.js +0 -125
  30. package/dist/squad/charter.test.js +0 -89
  31. package/dist/squad/context.js +0 -48
  32. package/dist/squad/context.test.js +0 -59
  33. package/dist/squad/discovery.js +0 -268
  34. package/dist/squad/discovery.test.js +0 -154
  35. package/dist/squad/index.js +0 -9
  36. package/dist/squad/init-cli.js +0 -109
  37. package/dist/squad/init.js +0 -395
  38. package/dist/squad/init.test.js +0 -351
  39. package/dist/squad/mirror.js +0 -83
  40. package/dist/squad/mirror.scheduler.js +0 -80
  41. package/dist/squad/mirror.scheduler.test.js +0 -197
  42. package/dist/squad/mirror.test.js +0 -172
  43. package/dist/squad/registry.js +0 -162
  44. package/dist/squad/registry.test.js +0 -31
  45. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  46. package/dist/squad/squad-session-routing.test.js +0 -260
  47. package/dist/squad/types.js +0 -4
  48. package/dist/squad/worktree.js +0 -295
  49. package/dist/squad/worktree.test.js +0 -189
  50. package/dist/store/squad-sessions.test.js +0 -341
  51. package/web/dist/assets/index-IgSOXx_a.js +0 -219
  52. package/web/dist/assets/index-IgSOXx_a.js.map +0 -1
@@ -1,197 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { DecisionsSyncScheduler, DEFAULT_DECISIONS_SYNC_INTERVAL_MS } from './mirror.scheduler.js';
4
- function makeFakeInterval() {
5
- let nextId = 1;
6
- const timers = new Map();
7
- const setIntervalImpl = (cb, ms) => {
8
- const id = nextId++;
9
- timers.set(id, { cb, ms });
10
- return { id, unref: () => { } };
11
- };
12
- const clearIntervalImpl = (handle) => {
13
- timers.delete(handle.id);
14
- };
15
- const tick = () => {
16
- for (const { cb } of timers.values())
17
- cb();
18
- };
19
- const activeCount = () => timers.size;
20
- return { setIntervalImpl, clearIntervalImpl, tick, activeCount };
21
- }
22
- // ---------------------------------------------------------------------------
23
- // Tests
24
- // ---------------------------------------------------------------------------
25
- test('DecisionsSyncScheduler: default interval matches 5 minutes', () => {
26
- assert.equal(DEFAULT_DECISIONS_SYNC_INTERVAL_MS, 300_000);
27
- });
28
- test('DecisionsSyncScheduler: start registers a timer with the configured interval', () => {
29
- const intervals = [];
30
- const handles = [];
31
- const setIntervalImpl = (cb, ms) => {
32
- intervals.push(ms);
33
- const handle = { id: 1, unref: () => { } };
34
- handles.push(handle);
35
- return handle;
36
- };
37
- const clearIntervalImpl = (_h) => { };
38
- const scheduler = new DecisionsSyncScheduler({
39
- intervalMs: 60_000,
40
- getRegisteredProjectRoots: () => [],
41
- syncFn: async () => null,
42
- setIntervalImpl: setIntervalImpl,
43
- clearIntervalImpl: clearIntervalImpl,
44
- });
45
- scheduler.start();
46
- assert.equal(intervals.length, 1, 'should register exactly one interval');
47
- assert.equal(intervals[0], 60_000, 'interval should match configured value');
48
- });
49
- test('DecisionsSyncScheduler: does not register a timer when intervalMs <= 0', () => {
50
- let registered = 0;
51
- const setIntervalImpl = (_cb, _ms) => {
52
- registered++;
53
- return { id: 1 };
54
- };
55
- const clearIntervalImpl = (_h) => { };
56
- const scheduler = new DecisionsSyncScheduler({
57
- intervalMs: 0,
58
- getRegisteredProjectRoots: () => ['/some/project'],
59
- syncFn: async () => null,
60
- setIntervalImpl: setIntervalImpl,
61
- clearIntervalImpl: clearIntervalImpl,
62
- });
63
- scheduler.start();
64
- assert.equal(registered, 0, 'should not register any interval when disabled');
65
- });
66
- test('DecisionsSyncScheduler: tick calls syncFn once per registered project', async () => {
67
- const synced = [];
68
- const scheduler = new DecisionsSyncScheduler({
69
- intervalMs: 60_000,
70
- getRegisteredProjectRoots: () => ['/project/alpha', '/project/beta', '/project/gamma'],
71
- syncFn: async (root) => { synced.push(root); return { entriesSynced: 1, wikiPath: 'pages/x.md' }; },
72
- setIntervalImpl: setInterval,
73
- clearIntervalImpl: clearInterval,
74
- });
75
- await scheduler.tick();
76
- assert.deepEqual(synced, ['/project/alpha', '/project/beta', '/project/gamma'], 'should call syncFn once per project in order');
77
- });
78
- test('DecisionsSyncScheduler: tick calls syncFn on each interval tick', async () => {
79
- const { setIntervalImpl, clearIntervalImpl, tick } = makeFakeInterval();
80
- const syncCalls = [];
81
- const scheduler = new DecisionsSyncScheduler({
82
- intervalMs: 60_000,
83
- getRegisteredProjectRoots: () => ['/project/alpha', '/project/beta'],
84
- syncFn: async (root) => { syncCalls.push(root); return null; },
85
- setIntervalImpl: setIntervalImpl,
86
- clearIntervalImpl: clearIntervalImpl,
87
- });
88
- scheduler.start();
89
- // Simulate two interval ticks — we need to await async propagation
90
- tick();
91
- await new Promise(r => setImmediate(r));
92
- tick();
93
- await new Promise(r => setImmediate(r));
94
- // Two ticks × two projects = 4 sync calls
95
- assert.equal(syncCalls.length, 4, 'should call syncFn 2 projects × 2 ticks = 4 times');
96
- assert.ok(syncCalls.filter(r => r === '/project/alpha').length === 2, 'alpha synced twice');
97
- assert.ok(syncCalls.filter(r => r === '/project/beta').length === 2, 'beta synced twice');
98
- });
99
- test('DecisionsSyncScheduler: stop clears the timer', () => {
100
- const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
101
- const scheduler = new DecisionsSyncScheduler({
102
- intervalMs: 60_000,
103
- getRegisteredProjectRoots: () => ['/project/x'],
104
- syncFn: async () => null,
105
- setIntervalImpl: setIntervalImpl,
106
- clearIntervalImpl: clearIntervalImpl,
107
- });
108
- scheduler.start();
109
- assert.equal(activeCount(), 1, 'timer should be registered after start');
110
- scheduler.stop();
111
- assert.equal(activeCount(), 0, 'timer should be cleared after stop');
112
- });
113
- test('DecisionsSyncScheduler: stop is idempotent', () => {
114
- const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
115
- const scheduler = new DecisionsSyncScheduler({
116
- intervalMs: 60_000,
117
- getRegisteredProjectRoots: () => [],
118
- syncFn: async () => null,
119
- setIntervalImpl: setIntervalImpl,
120
- clearIntervalImpl: clearIntervalImpl,
121
- });
122
- scheduler.start();
123
- scheduler.stop();
124
- scheduler.stop(); // second stop should not throw
125
- assert.equal(activeCount(), 0, 'timer count still 0 after double stop');
126
- });
127
- test('DecisionsSyncScheduler: tick skips sync when no projects registered', async () => {
128
- let syncCount = 0;
129
- const scheduler = new DecisionsSyncScheduler({
130
- intervalMs: 60_000,
131
- getRegisteredProjectRoots: () => [],
132
- syncFn: async () => { syncCount++; return null; },
133
- setIntervalImpl: setInterval,
134
- clearIntervalImpl: clearInterval,
135
- });
136
- await scheduler.tick();
137
- assert.equal(syncCount, 0, 'should not call syncFn when there are no registered projects');
138
- });
139
- test('DecisionsSyncScheduler: tick is resilient — continues after syncFn throws', async () => {
140
- const synced = [];
141
- const scheduler = new DecisionsSyncScheduler({
142
- intervalMs: 60_000,
143
- getRegisteredProjectRoots: () => ['/project/throws', '/project/ok'],
144
- syncFn: async (root) => {
145
- if (root === '/project/throws')
146
- throw new Error('boom');
147
- synced.push(root);
148
- return { entriesSynced: 1, wikiPath: 'pages/ok.md' };
149
- },
150
- setIntervalImpl: setInterval,
151
- clearIntervalImpl: clearInterval,
152
- });
153
- await scheduler.tick();
154
- assert.deepEqual(synced, ['/project/ok'], 'should continue syncing after an error in one project');
155
- });
156
- test('DecisionsSyncScheduler: start is idempotent — only one timer registered', () => {
157
- const { setIntervalImpl, clearIntervalImpl, activeCount } = makeFakeInterval();
158
- const scheduler = new DecisionsSyncScheduler({
159
- intervalMs: 60_000,
160
- getRegisteredProjectRoots: () => [],
161
- syncFn: async () => null,
162
- setIntervalImpl: setIntervalImpl,
163
- clearIntervalImpl: clearIntervalImpl,
164
- });
165
- scheduler.start();
166
- scheduler.start(); // second start should be a no-op
167
- assert.equal(activeCount(), 1, 'only one timer should be registered after double start');
168
- scheduler.stop();
169
- });
170
- test('DecisionsSyncScheduler: reads interval from CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS env var', () => {
171
- const original = process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
172
- process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS = '12345';
173
- try {
174
- const intervals = [];
175
- const scheduler = new DecisionsSyncScheduler({
176
- getRegisteredProjectRoots: () => [],
177
- syncFn: async () => null,
178
- setIntervalImpl: ((_cb, ms) => {
179
- intervals.push(ms);
180
- return { id: 99, unref: () => { } };
181
- }),
182
- clearIntervalImpl: (_h) => { },
183
- });
184
- scheduler.start();
185
- assert.equal(intervals[0], 12345, 'should use env var value for interval');
186
- scheduler.stop();
187
- }
188
- finally {
189
- if (original === undefined) {
190
- delete process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
191
- }
192
- else {
193
- process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS = original;
194
- }
195
- }
196
- });
197
- //# sourceMappingURL=mirror.scheduler.test.js.map
@@ -1,172 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
- import { join, basename } from "node:path";
4
- import test from "node:test";
5
- const repoRoot = process.cwd();
6
- const sandboxRoot = join(repoRoot, ".test-work", `squad-mirror-${process.pid}`);
7
- // Point CHAPTERHOUSE_HOME at sandbox so wiki writes land in an isolated location
8
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
- const mockProjectPath = join(repoRoot, "src/test/fixtures/mock-squad-repo");
10
- const mockLink = {
11
- taskId: "task-001",
12
- projectRoot: mockProjectPath,
13
- squadAgentSlug: "ripley",
14
- wikiDecisionPath: `pages/projects/${basename(mockProjectPath).toLowerCase().replace(/[^a-z0-9-]/g, "-")}/decisions.md`,
15
- };
16
- async function loadMirror() {
17
- return await import(new URL(`./mirror.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
18
- }
19
- test.before(() => {
20
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
21
- mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
22
- });
23
- test.after(() => {
24
- rmSync(sandboxRoot, { recursive: true, force: true });
25
- });
26
- test("projectDecisionWikiPath returns path matching pages/projects/{basename}/decisions.md", async () => {
27
- const m = await loadMirror();
28
- assert.equal(typeof m.projectDecisionWikiPath, "function", "projectDecisionWikiPath should be exported");
29
- const projectRoot = "/home/user/my-cool-project";
30
- const result = m.projectDecisionWikiPath(projectRoot);
31
- const expectedBasename = basename(projectRoot).toLowerCase().replace(/[^a-z0-9-]/g, "-");
32
- assert.ok(typeof result === "string" && result.startsWith("pages/projects/"), `expected path to start with 'pages/projects/', got: ${result}`);
33
- assert.ok(result.includes(expectedBasename), `expected path to include project basename '${expectedBasename}', got: ${result}`);
34
- assert.ok(result.endsWith("decisions.md"), `expected path to end with 'decisions.md', got: ${result}`);
35
- });
36
- test("mirrorDecisionToWiki creates a wiki page if it doesn't exist", async () => {
37
- const m = await loadMirror();
38
- assert.equal(typeof m.mirrorDecisionToWiki, "function", "mirrorDecisionToWiki should be exported");
39
- // Clean state for this test
40
- rmSync(sandboxRoot, { recursive: true, force: true });
41
- mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
42
- const wikiPath = await m.mirrorDecisionToWiki(mockLink, "Designed module layout", "Adopted layered architecture");
43
- assert.ok(typeof wikiPath === "string" && wikiPath.length > 0, "should return a wiki path");
44
- const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
45
- const fullPath = join(wikiDir, ...wikiPath.split("/"));
46
- assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
47
- });
48
- test("mirrorDecisionToWiki appends to an existing wiki page", async () => {
49
- const m = await loadMirror();
50
- await m.mirrorDecisionToWiki(mockLink, "First decision", "First result");
51
- const dallasLink = { ...mockLink, taskId: "task-002", squadAgentSlug: "dallas" };
52
- await m.mirrorDecisionToWiki(dallasLink, "Second decision", "Second result");
53
- const wikiPath = m.projectDecisionWikiPath(mockProjectPath);
54
- const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
55
- const fullPath = join(wikiDir, ...wikiPath.split("/"));
56
- assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
57
- const content = readFileSync(fullPath, "utf-8");
58
- assert.ok(content.includes("First decision"), "first entry should be present after append");
59
- assert.ok(content.includes("Second decision"), "second entry should be present after append");
60
- });
61
- test("appended entry includes agent slug, task summary, and result summary", async () => {
62
- const m = await loadMirror();
63
- const dallasLink = { ...mockLink, taskId: "task-003", squadAgentSlug: "dallas" };
64
- await m.mirrorDecisionToWiki(dallasLink, "API design task", "Chose REST over GraphQL");
65
- const wikiPath = m.projectDecisionWikiPath(mockProjectPath);
66
- const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
67
- const fullPath = join(wikiDir, ...wikiPath.split("/"));
68
- const content = readFileSync(fullPath, "utf-8");
69
- assert.ok(content.includes("dallas"), "entry should include agent slug 'dallas'");
70
- assert.ok(content.includes("API design task"), "entry should include task summary");
71
- assert.ok(content.includes("Chose REST over GraphQL"), "entry should include result summary");
72
- });
73
- test("mirrorDecisionToWiki returns the wiki page path", async () => {
74
- const m = await loadMirror();
75
- const returned = await m.mirrorDecisionToWiki({ ...mockLink, taskId: "task-final" }, "Final check", "All good");
76
- const expected = m.projectDecisionWikiPath(mockProjectPath);
77
- assert.equal(returned, expected, "mirrorDecisionToWiki should return the wiki page path");
78
- });
79
- // ---------------------------------------------------------------------------
80
- // syncDecisionsFileToWiki tests
81
- // ---------------------------------------------------------------------------
82
- async function loadMirrorFresh() {
83
- return await import(new URL(`./mirror.js?fresh=${Date.now()}-${Math.random()}`, import.meta.url).href);
84
- }
85
- test("syncDecisionsFileToWiki returns null when decisions.md does not exist", async () => {
86
- const m = await loadMirrorFresh();
87
- const sync = m.syncDecisionsFileToWiki;
88
- const result = await sync("/nonexistent/path/that/does/not/exist");
89
- assert.equal(result, null, "should return null for missing decisions.md");
90
- });
91
- test("syncDecisionsFileToWiki writes decisions.md content to the wiki page", async () => {
92
- const m = await loadMirrorFresh();
93
- const sync = m.syncDecisionsFileToWiki;
94
- const projectDecisionWikiPath = m.projectDecisionWikiPath;
95
- // Set up a temp project with a .squad/decisions.md
96
- const projectDir = join(repoRoot, ".test-work", `sync-test-${process.pid}-1`);
97
- const squadDir = join(projectDir, ".squad");
98
- mkdirSync(squadDir, { recursive: true });
99
- const decisionsContent = `# Squad Decisions\n\n## Decision 1\nUse TypeScript.\n\n## Decision 2\nUse SQLite.\n`;
100
- const { writeFileSync } = await import("node:fs");
101
- writeFileSync(join(squadDir, "decisions.md"), decisionsContent, "utf-8");
102
- try {
103
- const result = await sync(projectDir);
104
- assert.ok(result !== null, "should return a result object, not null");
105
- assert.equal(result.entriesSynced, 2, "should count 2 second-level headings");
106
- const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
107
- const wikiPath = projectDecisionWikiPath(projectDir);
108
- const fullPath = join(wikiDir, ...wikiPath.split("/"));
109
- assert.ok(existsSync(fullPath), `wiki page should exist at ${fullPath}`);
110
- const written = readFileSync(fullPath, "utf-8");
111
- assert.ok(written.includes("Use TypeScript"), "wiki page should contain decisions content");
112
- assert.ok(written.includes("Use SQLite"), "wiki page should contain second decision");
113
- assert.ok(written.includes("Last synced:"), "wiki page should include sync timestamp header");
114
- }
115
- finally {
116
- rmSync(projectDir, { recursive: true, force: true });
117
- }
118
- });
119
- test("syncDecisionsFileToWiki overwrites (not appends) on re-run with updated content", async () => {
120
- const m = await loadMirrorFresh();
121
- const sync = m.syncDecisionsFileToWiki;
122
- const projectDecisionWikiPath = m.projectDecisionWikiPath;
123
- const projectDir = join(repoRoot, ".test-work", `sync-test-${process.pid}-2`);
124
- const squadDir = join(projectDir, ".squad");
125
- mkdirSync(squadDir, { recursive: true });
126
- const { writeFileSync } = await import("node:fs");
127
- const decisionsFile = join(squadDir, "decisions.md");
128
- // First sync
129
- writeFileSync(decisionsFile, "## Original decision\nContent A.\n", "utf-8");
130
- await sync(projectDir);
131
- // Update and re-sync
132
- writeFileSync(decisionsFile, "## Updated decision\nContent B.\n", "utf-8");
133
- await sync(projectDir);
134
- try {
135
- const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
136
- const wikiPath = projectDecisionWikiPath(projectDir);
137
- const fullPath = join(wikiDir, ...wikiPath.split("/"));
138
- const written = readFileSync(fullPath, "utf-8");
139
- assert.ok(written.includes("Content B"), "wiki should contain updated content after re-sync");
140
- assert.ok(!written.includes("Content A"), "wiki should NOT contain old content after overwrite");
141
- }
142
- finally {
143
- rmSync(projectDir, { recursive: true, force: true });
144
- }
145
- });
146
- test("checkDecisionsInbox returns 0 and empty array when inbox is absent", async () => {
147
- const m = await loadMirrorFresh();
148
- const checkInbox = m.checkDecisionsInbox;
149
- const result = await checkInbox("/nonexistent/project/path");
150
- assert.equal(result.count, 0, "count should be 0 for missing inbox");
151
- assert.deepEqual(result.files, [], "files should be empty array for missing inbox");
152
- });
153
- test("checkDecisionsInbox returns files in inbox", async () => {
154
- const m = await loadMirrorFresh();
155
- const checkInbox = m.checkDecisionsInbox;
156
- const projectDir = join(repoRoot, ".test-work", `inbox-test-${process.pid}`);
157
- const inboxDir = join(projectDir, ".squad", "decisions", "inbox");
158
- mkdirSync(inboxDir, { recursive: true });
159
- const { writeFileSync } = await import("node:fs");
160
- writeFileSync(join(inboxDir, "drop-one.md"), "# Drop", "utf-8");
161
- writeFileSync(join(inboxDir, "drop-two.md"), "# Drop", "utf-8");
162
- try {
163
- const result = await checkInbox(projectDir);
164
- assert.equal(result.count, 2, "should count 2 inbox files");
165
- assert.ok(result.files.includes("drop-one.md"), "should list drop-one.md");
166
- assert.ok(result.files.includes("drop-two.md"), "should list drop-two.md");
167
- }
168
- finally {
169
- rmSync(projectDir, { recursive: true, force: true });
170
- }
171
- });
172
- //# sourceMappingURL=mirror.test.js.map
@@ -1,162 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getDb } from '../store/db.js';
4
- import { loadProjectSquad } from './discovery.js';
5
- function extractRoleFromCharter(content) {
6
- const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/);
7
- if (roleMatch)
8
- return roleMatch[1].trim();
9
- const headingMatch = content.match(/^#\s+.+?—\s*(.+)/m);
10
- if (headingMatch)
11
- return headingMatch[1].trim();
12
- const h2Match = content.match(/^##\s+(.+)/m);
13
- if (h2Match)
14
- return h2Match[1].trim();
15
- return 'Agent';
16
- }
17
- function extractDescriptionFromCharter(content) {
18
- const blockquoteMatch = content.match(/^#[^\n]*\n+>\s*(.+)/m);
19
- if (blockquoteMatch)
20
- return blockquoteMatch[1].trim();
21
- const lines = content.split('\n');
22
- let pastHeading = false;
23
- for (const line of lines) {
24
- if (!pastHeading && line.startsWith('#')) {
25
- pastHeading = true;
26
- continue;
27
- }
28
- if (pastHeading && line.trim() && !line.startsWith('#') && !line.startsWith('>')) {
29
- return line.trim();
30
- }
31
- }
32
- return undefined;
33
- }
34
- function loadProjectSquadSync(projectRoot) {
35
- const squadDir = join(projectRoot, '.squad');
36
- if (!existsSync(squadDir)) {
37
- return null;
38
- }
39
- const agentsDir = join(squadDir, 'agents');
40
- const agents = [];
41
- if (existsSync(agentsDir)) {
42
- for (const entry of readdirSync(agentsDir)) {
43
- const agentDir = join(agentsDir, entry);
44
- try {
45
- if (!statSync(agentDir).isDirectory())
46
- continue;
47
- }
48
- catch {
49
- continue;
50
- }
51
- const charterPath = join(agentDir, 'charter.md');
52
- if (!existsSync(charterPath))
53
- continue;
54
- let charterContent = '';
55
- try {
56
- charterContent = readFileSync(charterPath, 'utf-8');
57
- }
58
- catch {
59
- continue;
60
- }
61
- agents.push({
62
- slug: entry,
63
- mention: `@${entry}`,
64
- role: extractRoleFromCharter(charterContent),
65
- description: extractDescriptionFromCharter(charterContent),
66
- charterPath,
67
- origin: 'project-squad',
68
- });
69
- }
70
- }
71
- return {
72
- projectRoot,
73
- squadDir,
74
- teamDir: squadDir,
75
- personalDir: null,
76
- mode: 'local',
77
- projectKey: null,
78
- config: {},
79
- agents,
80
- decisionsPath: join(squadDir, 'decisions.md'),
81
- routingPath: existsSync(join(squadDir, 'routing.md')) ? join(squadDir, 'routing.md') : undefined,
82
- loadedAt: new Date().toISOString(),
83
- };
84
- }
85
- export function getProjectSquad(projectRoot) {
86
- return loadProjectSquadSync(projectRoot);
87
- }
88
- export function listProjectSquadAgents(projectRoot) {
89
- return getProjectSquad(projectRoot)?.agents ?? [];
90
- }
91
- export function findSquadAgent(projectRoot, mention) {
92
- const agents = listProjectSquadAgents(projectRoot);
93
- const slug = mention.replace(/^@/, '').toLowerCase();
94
- const normalised = `@${slug}`;
95
- return agents.find((a) => a.mention.toLowerCase() === normalised || a.slug.toLowerCase() === slug) ?? null;
96
- }
97
- export function renderProjectAgentRoster(projectRoot) {
98
- const agents = listProjectSquadAgents(projectRoot);
99
- if (agents.length === 0)
100
- return '';
101
- return agents
102
- .map((agent) => `- **${agent.mention}** — ${agent.role}${agent.description ? ` — ${agent.description}` : ''}`)
103
- .join('\n');
104
- }
105
- export async function registerProject(projectRoot) {
106
- try {
107
- const db = getDb();
108
- // Ensure the row exists before marking registered
109
- const row = db.prepare(`SELECT project_root FROM project_squads WHERE project_root = ?`)
110
- .get(projectRoot);
111
- if (!row) {
112
- // Load first so the row is created
113
- const ctx = await loadProjectSquad(projectRoot);
114
- if (!ctx)
115
- return false;
116
- }
117
- db.prepare(`UPDATE project_squads SET registered = 1 WHERE project_root = ?`).run(projectRoot);
118
- // Refresh cache
119
- await loadProjectSquad(projectRoot);
120
- return true;
121
- }
122
- catch {
123
- return false;
124
- }
125
- }
126
- export async function unregisterProject(projectRoot) {
127
- try {
128
- const db = getDb();
129
- db.prepare(`DELETE FROM squad_agents WHERE project_root = ?`).run(projectRoot);
130
- db.prepare(`DELETE FROM project_squads WHERE project_root = ?`).run(projectRoot);
131
- return true;
132
- }
133
- catch {
134
- return false;
135
- }
136
- }
137
- export async function listRegisteredProjects() {
138
- try {
139
- const db = getDb();
140
- const rows = db.prepare(`
141
- SELECT
142
- ps.project_root,
143
- ps.squad_dir,
144
- ps.loaded_at,
145
- COUNT(sa.slug) as agent_count
146
- FROM project_squads ps
147
- LEFT JOIN squad_agents sa ON sa.project_root = ps.project_root
148
- WHERE ps.registered = 1
149
- GROUP BY ps.project_root
150
- `).all();
151
- return rows.map(r => ({
152
- projectRoot: r.project_root,
153
- squadDir: r.squad_dir,
154
- agentCount: r.agent_count,
155
- loadedAt: r.loaded_at,
156
- }));
157
- }
158
- catch {
159
- return [];
160
- }
161
- }
162
- //# sourceMappingURL=registry.js.map
@@ -1,31 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import test from "node:test";
5
- const repoRoot = process.cwd();
6
- const fixtureRoot = join(repoRoot, "src/test/fixtures/mock-squad-repo");
7
- const sandboxRoot = join(repoRoot, ".test-work", `squad-registry-${process.pid}`);
8
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
- async function loadRegistry() {
10
- return await import(new URL(`./registry.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
11
- }
12
- test.before(() => {
13
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
14
- mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
15
- });
16
- test.after(() => {
17
- rmSync(sandboxRoot, { recursive: true, force: true });
18
- });
19
- test("findSquadAgent returns a fixture agent descriptor synchronously", async () => {
20
- const m = await loadRegistry();
21
- const result = m.findSquadAgent(fixtureRoot, "ripley");
22
- assert.equal(result?.slug, "ripley");
23
- assert.equal(result?.mention, "@ripley");
24
- });
25
- test("renderProjectAgentRoster returns squad roster lines synchronously", async () => {
26
- const m = await loadRegistry();
27
- const roster = m.renderProjectAgentRoster(fixtureRoot);
28
- assert.match(roster, /@ripley/);
29
- assert.match(roster, /@dallas/);
30
- });
31
- //# sourceMappingURL=registry.test.js.map