chapterhouse 0.3.13 → 0.3.15

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 (60) 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 +11 -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/system-message.js +7 -0
  12. package/dist/copilot/task-event-log.js +5 -5
  13. package/dist/copilot/task-event-log.test.js +68 -142
  14. package/dist/copilot/tools.js +72 -132
  15. package/dist/daemon.js +6 -22
  16. package/dist/store/db.js +2 -50
  17. package/dist/store/db.test.js +0 -45
  18. package/dist/wiki/fs.js +5 -0
  19. package/dist/wiki/index-manager.js +92 -17
  20. package/dist/wiki/index-manager.test.js +19 -0
  21. package/dist/wiki/migrate-topics.js +132 -0
  22. package/dist/wiki/migrate-topics.test.js +57 -0
  23. package/dist/wiki/topic-structure.js +167 -0
  24. package/dist/wiki/topic-structure.test.js +74 -0
  25. package/package.json +1 -3
  26. package/web/dist/assets/index-BlIWCM11.js +217 -0
  27. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  28. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  29. package/web/dist/index.html +2 -2
  30. package/dist/api/ralph.js +0 -153
  31. package/dist/api/ralph.test.js +0 -101
  32. package/dist/copilot/agents.squad.test.js +0 -72
  33. package/dist/copilot/hooks.js +0 -157
  34. package/dist/copilot/hooks.test.js +0 -315
  35. package/dist/copilot/squad-event-bus.js +0 -27
  36. package/dist/copilot/tools.squad.test.js +0 -168
  37. package/dist/squad/charter.js +0 -125
  38. package/dist/squad/charter.test.js +0 -89
  39. package/dist/squad/context.js +0 -48
  40. package/dist/squad/context.test.js +0 -59
  41. package/dist/squad/discovery.js +0 -268
  42. package/dist/squad/discovery.test.js +0 -154
  43. package/dist/squad/index.js +0 -9
  44. package/dist/squad/init-cli.js +0 -109
  45. package/dist/squad/init.js +0 -395
  46. package/dist/squad/init.test.js +0 -351
  47. package/dist/squad/mirror.js +0 -83
  48. package/dist/squad/mirror.scheduler.js +0 -80
  49. package/dist/squad/mirror.scheduler.test.js +0 -197
  50. package/dist/squad/mirror.test.js +0 -172
  51. package/dist/squad/registry.js +0 -162
  52. package/dist/squad/registry.test.js +0 -31
  53. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  54. package/dist/squad/squad-session-routing.test.js +0 -260
  55. package/dist/squad/types.js +0 -4
  56. package/dist/squad/worktree.js +0 -295
  57. package/dist/squad/worktree.test.js +0 -189
  58. package/dist/store/squad-sessions.test.js +0 -341
  59. package/web/dist/assets/index-IgSOXx_a.js +0 -219
  60. package/web/dist/assets/index-IgSOXx_a.js.map +0 -1
@@ -1,89 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { join } from "node:path";
3
- import test from "node:test";
4
- const repoRoot = process.cwd();
5
- const fixtureRoot = join(repoRoot, "src/test/fixtures/mock-squad-repo");
6
- // Minimal ProjectSquadContext shape that charter functions need
7
- const ripleyAgent = {
8
- slug: "ripley",
9
- mention: "@ripley",
10
- role: "Lead",
11
- charterPath: join(fixtureRoot, ".squad/agents/ripley/charter.md"),
12
- origin: "project-squad",
13
- };
14
- const mockContext = {
15
- projectRoot: fixtureRoot,
16
- squadDir: join(fixtureRoot, ".squad"),
17
- teamDir: join(fixtureRoot, ".squad"),
18
- personalDir: null,
19
- mode: "local",
20
- projectKey: null,
21
- config: {},
22
- agents: [
23
- ripleyAgent,
24
- {
25
- slug: "dallas",
26
- mention: "@dallas",
27
- role: "Backend Dev",
28
- charterPath: join(fixtureRoot, ".squad/agents/dallas/charter.md"),
29
- origin: "project-squad",
30
- },
31
- ],
32
- decisionsPath: join(fixtureRoot, ".squad/decisions.md"),
33
- loadedAt: new Date().toISOString(),
34
- };
35
- async function loadCharter() {
36
- return await import(new URL(`./charter.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
37
- }
38
- test("buildSquadSystemPrefix includes '## Squad project context' header", async () => {
39
- const m = await loadCharter();
40
- assert.equal(typeof m.buildSquadSystemPrefix, "function", "buildSquadSystemPrefix should be exported");
41
- const result = m.buildSquadSystemPrefix(mockContext, ripleyAgent);
42
- assert.ok(typeof result === "string" && result.includes("## Squad project context"), `Expected '## Squad project context' in output, got: ${result.slice(0, 200)}`);
43
- });
44
- test("buildSquadSystemPrefix includes the charter file content", async () => {
45
- const m = await loadCharter();
46
- const result = m.buildSquadSystemPrefix(mockContext, ripleyAgent);
47
- // ripley charter contains "Expertise: Architecture"
48
- assert.ok(typeof result === "string" && result.includes("Architecture"), `Expected charter content (Expertise: Architecture) in prefix, got: ${result.slice(0, 300)}`);
49
- });
50
- test("buildSquadSystemPrefix includes '## Recent squad decisions'", async () => {
51
- const m = await loadCharter();
52
- const result = m.buildSquadSystemPrefix(mockContext, ripleyAgent);
53
- assert.ok(typeof result === "string" && result.includes("## Recent squad decisions"), `Expected '## Recent squad decisions' in output, got: ${result.slice(0, 300)}`);
54
- });
55
- test("readRecentDecisions returns decisions.md content for valid context", async () => {
56
- const m = await loadCharter();
57
- assert.equal(typeof m.readRecentDecisions, "function", "readRecentDecisions should be exported");
58
- const result = m.readRecentDecisions(mockContext);
59
- assert.ok(typeof result === "string" && result.length > 0, "should return non-empty decisions content");
60
- assert.ok(result.includes("Test Decision"), "should include fixture decision content");
61
- });
62
- test("readRecentDecisions returns empty string for non-existent decisions.md path", async () => {
63
- const m = await loadCharter();
64
- const ctxNoDecisions = {
65
- ...mockContext,
66
- decisionsPath: join(fixtureRoot, ".squad/decisions-DOES-NOT-EXIST.md"),
67
- };
68
- const result = m.readRecentDecisions(ctxNoDecisions);
69
- assert.equal(result, "", "should return empty string when decisions file does not exist");
70
- });
71
- test("readRecentDecisions truncates to maxChars limit", async () => {
72
- const m = await loadCharter();
73
- const result = m.readRecentDecisions(mockContext, 10);
74
- assert.ok(typeof result === "string" && result.length <= 10, `result should be at most 10 chars, got ${result.length}: "${result}"`);
75
- });
76
- test("buildSquadSystemPrefix handles missing charter file gracefully (no throw)", async () => {
77
- const m = await loadCharter();
78
- const ghostAgent = {
79
- slug: "ghost",
80
- mention: "@ghost",
81
- role: "Unknown",
82
- charterPath: join(fixtureRoot, ".squad/agents/ghost/charter.md"), // does not exist
83
- origin: "project-squad",
84
- };
85
- assert.doesNotThrow(() => {
86
- m.buildSquadSystemPrefix(mockContext, ghostAgent);
87
- }, "buildSquadSystemPrefix should not throw when charter file is missing");
88
- });
89
- //# sourceMappingURL=charter.test.js.map
@@ -1,48 +0,0 @@
1
- import { homedir } from 'os';
2
- import { resolve } from 'path';
3
- // ---------------------------------------------------------------------------
4
- // Channel → project mapping (in-memory, not persisted)
5
- // ---------------------------------------------------------------------------
6
- const channelProjectMap = new Map();
7
- export function setChannelProject(channelKey, projectRoot) {
8
- channelProjectMap.set(channelKey, projectRoot);
9
- }
10
- export function getChannelProject(channelKey) {
11
- return channelProjectMap.get(channelKey);
12
- }
13
- export function clearChannelProject(channelKey) {
14
- channelProjectMap.delete(channelKey);
15
- }
16
- // ---------------------------------------------------------------------------
17
- // Path extraction and normalisation
18
- // ---------------------------------------------------------------------------
19
- /**
20
- * Looks for a `cwd:` prefix or an absolute path (starts with `/` or `~/`) in
21
- * the prompt text. Returns the first match, or null if none found.
22
- */
23
- export function extractProjectPathFromPrompt(prompt) {
24
- // Explicit cwd: prefix — e.g. "cwd:/home/user/myproject do something"
25
- const cwdMatch = prompt.match(/(?:^|\s)cwd:(\S+)/);
26
- if (cwdMatch)
27
- return normalizeProjectPath(cwdMatch[1]);
28
- // Absolute Unix path
29
- const absMatch = prompt.match(/(?:^|\s)(\/\S+)/);
30
- if (absMatch)
31
- return normalizeProjectPath(absMatch[1]);
32
- // Home-relative path
33
- const homeMatch = prompt.match(/(?:^|\s)(~\/\S+)/);
34
- if (homeMatch)
35
- return normalizeProjectPath(homeMatch[1]);
36
- return null;
37
- }
38
- /**
39
- * Resolves `~` to the home directory and then calls `path.resolve` to get an
40
- * absolute, normalised path.
41
- */
42
- export function normalizeProjectPath(projectPath) {
43
- const expanded = projectPath.startsWith('~/')
44
- ? projectPath.replace(/^~/, homedir())
45
- : projectPath;
46
- return resolve(expanded);
47
- }
48
- //# sourceMappingURL=context.js.map
@@ -1,59 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import test from "node:test";
5
- async function loadContext() {
6
- return await import(new URL(`./context.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
7
- }
8
- test("extractProjectPathFromPrompt returns null for a plain prompt with no path", async () => {
9
- const m = await loadContext();
10
- assert.equal(typeof m.extractProjectPathFromPrompt, "function", "extractProjectPathFromPrompt should be exported");
11
- const result = m.extractProjectPathFromPrompt("Hey, what is the status of the sprint?");
12
- assert.equal(result, null);
13
- });
14
- test("extractProjectPathFromPrompt extracts path from cwd:/home/user/my-project", async () => {
15
- const m = await loadContext();
16
- const result = m.extractProjectPathFromPrompt("cwd:/home/user/my-project\nWhat are the recent decisions?");
17
- assert.equal(result, "/home/user/my-project");
18
- });
19
- test("extractProjectPathFromPrompt extracts absolute path from prompt text", async () => {
20
- const m = await loadContext();
21
- const result = m.extractProjectPathFromPrompt("Looking at project /home/user/my-project — what agents are available?");
22
- assert.equal(result, "/home/user/my-project");
23
- });
24
- test("normalizeProjectPath resolves ~/foo to home dir + /foo", async () => {
25
- const m = await loadContext();
26
- assert.equal(typeof m.normalizeProjectPath, "function", "normalizeProjectPath should be exported");
27
- const result = m.normalizeProjectPath("~/foo");
28
- assert.equal(result, join(homedir(), "foo"));
29
- });
30
- test("normalizeProjectPath passes through absolute paths unchanged", async () => {
31
- const m = await loadContext();
32
- const result = m.normalizeProjectPath("/home/user/project");
33
- assert.equal(result, "/home/user/project");
34
- });
35
- test("setChannelProject + getChannelProject round-trips correctly", async () => {
36
- const m = await loadContext();
37
- assert.equal(typeof m.setChannelProject, "function", "setChannelProject should be exported");
38
- assert.equal(typeof m.getChannelProject, "function", "getChannelProject should be exported");
39
- m.setChannelProject("channel-abc", "/home/user/project-alpha");
40
- const result = m.getChannelProject("channel-abc");
41
- assert.equal(result, "/home/user/project-alpha");
42
- });
43
- test("clearChannelProject removes the mapping", async () => {
44
- const m = await loadContext();
45
- assert.equal(typeof m.clearChannelProject, "function", "clearChannelProject should be exported");
46
- m.setChannelProject("channel-to-clear", "/home/user/some-project");
47
- assert.equal(m.getChannelProject("channel-to-clear"), "/home/user/some-project");
48
- m.clearChannelProject("channel-to-clear");
49
- assert.equal(m.getChannelProject("channel-to-clear"), undefined, "cleared channel should return undefined");
50
- });
51
- test("different channel keys are independent", async () => {
52
- const m = await loadContext();
53
- m.setChannelProject("channel-x", "/projects/x");
54
- m.setChannelProject("channel-y", "/projects/y");
55
- assert.equal(m.getChannelProject("channel-x"), "/projects/x");
56
- assert.equal(m.getChannelProject("channel-y"), "/projects/y");
57
- assert.equal(m.getChannelProject("channel-z"), undefined, "unknown channel key should return undefined");
58
- });
59
- //# sourceMappingURL=context.test.js.map
@@ -1,268 +0,0 @@
1
- import { existsSync } from 'fs';
2
- import { join } from 'path';
3
- import { FSStorageProvider, SquadState, NotFoundError } from '@bradygaster/squad-sdk';
4
- import { getDb } from '../store/db.js';
5
- // ---------------------------------------------------------------------------
6
- // DB schema — created lazily on first use
7
- // ---------------------------------------------------------------------------
8
- function ensureSquadTables() {
9
- const db = getDb();
10
- db.exec(`
11
- CREATE TABLE IF NOT EXISTS project_squads (
12
- project_root TEXT PRIMARY KEY,
13
- squad_dir TEXT NOT NULL,
14
- team_dir TEXT NOT NULL,
15
- personal_dir TEXT,
16
- mode TEXT NOT NULL CHECK(mode IN ('local', 'remote')),
17
- project_key TEXT,
18
- config_source TEXT,
19
- registered INTEGER NOT NULL DEFAULT 0,
20
- loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
21
- )
22
- `);
23
- db.exec(`
24
- CREATE TABLE IF NOT EXISTS squad_agents (
25
- project_root TEXT NOT NULL,
26
- slug TEXT NOT NULL,
27
- role TEXT NOT NULL,
28
- description TEXT,
29
- charter_path TEXT NOT NULL,
30
- model_preference TEXT,
31
- tools_json TEXT,
32
- capabilities_json TEXT,
33
- stale INTEGER NOT NULL DEFAULT 0,
34
- loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
- PRIMARY KEY (project_root, slug)
36
- )
37
- `);
38
- db.exec(`
39
- CREATE TABLE IF NOT EXISTS squad_task_links (
40
- task_id TEXT PRIMARY KEY,
41
- project_root TEXT NOT NULL,
42
- squad_agent_slug TEXT NOT NULL,
43
- wiki_decision_path TEXT NOT NULL
44
- )
45
- `);
46
- }
47
- // ---------------------------------------------------------------------------
48
- // Charter parsing helpers
49
- // ---------------------------------------------------------------------------
50
- function extractRoleFromCharter(content) {
51
- // Look for "**Role:**" in identity section
52
- const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/);
53
- if (roleMatch)
54
- return roleMatch[1].trim();
55
- // Fallback: first heading text after "—"
56
- const headingMatch = content.match(/^#\s+.+?—\s*(.+)/m);
57
- if (headingMatch)
58
- return headingMatch[1].trim();
59
- // Second heading as role
60
- const h2Match = content.match(/^##\s+(.+)/m);
61
- if (h2Match)
62
- return h2Match[1].trim();
63
- return 'Agent';
64
- }
65
- function extractDescriptionFromCharter(content) {
66
- // Blockquote after title heading
67
- const blockquoteMatch = content.match(/^#[^\n]*\n+>\s*(.+)/m);
68
- if (blockquoteMatch)
69
- return blockquoteMatch[1].trim();
70
- // First non-empty paragraph after heading
71
- const lines = content.split('\n');
72
- let pastHeading = false;
73
- for (const line of lines) {
74
- if (!pastHeading && line.startsWith('#')) {
75
- pastHeading = true;
76
- continue;
77
- }
78
- if (pastHeading && line.trim() && !line.startsWith('#') && !line.startsWith('>')) {
79
- return line.trim();
80
- }
81
- }
82
- return undefined;
83
- }
84
- // ---------------------------------------------------------------------------
85
- // Core discovery — reads repo filesystem, no DB required
86
- // ---------------------------------------------------------------------------
87
- export async function resolveProjectSquad(projectPath) {
88
- const squadDir = join(projectPath, '.squad');
89
- if (!existsSync(squadDir))
90
- return null;
91
- // Try SDK path resolution with graceful fallback
92
- let resolvedSquadDir = squadDir;
93
- let mode = 'local';
94
- let projectKey = null;
95
- let configSource;
96
- try {
97
- const { resolveSquad } = await import('@bradygaster/squad-sdk');
98
- const resolved = resolveSquad(projectPath);
99
- if (resolved)
100
- resolvedSquadDir = resolved;
101
- }
102
- catch {
103
- // SDK not available — use manual path resolution
104
- }
105
- // Enumerate agents from .squad/agents/ via SDK AgentsCollection.
106
- // FSStorageProvider wraps the same fs calls with typed returns and ENOENT safety.
107
- const agents = [];
108
- const stateRoot = resolvedSquadDir.endsWith('/.squad')
109
- ? resolvedSquadDir.slice(0, -'/.squad'.length)
110
- : projectPath;
111
- try {
112
- const storage = new FSStorageProvider();
113
- const state = SquadState.fromStorage(storage, stateRoot);
114
- const agentNames = await state.agents.list();
115
- for (const name of agentNames) {
116
- const charterPath = join(resolvedSquadDir, 'agents', name, 'charter.md');
117
- let charterContent;
118
- try {
119
- charterContent = await state.agents.get(name).charter();
120
- }
121
- catch (err) {
122
- if (err instanceof NotFoundError)
123
- continue; // no charter.md — not a valid agent
124
- continue;
125
- }
126
- agents.push({
127
- slug: name,
128
- mention: `@${name}`,
129
- role: extractRoleFromCharter(charterContent),
130
- description: extractDescriptionFromCharter(charterContent),
131
- charterPath,
132
- origin: 'project-squad',
133
- });
134
- }
135
- }
136
- catch { /* SDK unavailable — agents list stays empty */ }
137
- // Load squad config via SDK with fallback to empty config
138
- let squadConfig = {};
139
- try {
140
- const { loadConfig } = await import('@bradygaster/squad-sdk');
141
- const result = await loadConfig(resolvedSquadDir);
142
- squadConfig = result.config;
143
- configSource = result.source;
144
- }
145
- catch { /* SDK unavailable or config parse error — use empty config */ }
146
- const decisionsPath = join(resolvedSquadDir, 'decisions.md');
147
- const routingPath = join(resolvedSquadDir, 'routing.md');
148
- const now = new Date().toISOString();
149
- const context = {
150
- projectRoot: projectPath,
151
- squadDir: resolvedSquadDir,
152
- teamDir: resolvedSquadDir,
153
- personalDir: null,
154
- mode,
155
- projectKey,
156
- configSource,
157
- config: squadConfig,
158
- agents,
159
- decisionsPath,
160
- routingPath: existsSync(routingPath) ? routingPath : undefined,
161
- loadedAt: now,
162
- };
163
- // Persist to DB cache
164
- _persistSquadToDb(context);
165
- return context;
166
- }
167
- function _persistSquadToDb(context) {
168
- try {
169
- ensureSquadTables();
170
- const db = getDb();
171
- db.prepare(`
172
- INSERT OR REPLACE INTO project_squads
173
- (project_root, squad_dir, team_dir, personal_dir, mode, project_key, config_source, loaded_at)
174
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
175
- `).run(context.projectRoot, context.squadDir, context.teamDir, context.personalDir ?? null, context.mode, context.projectKey ?? null, context.configSource ?? null, context.loadedAt);
176
- // Remove agents no longer in repo
177
- const repoSlugs = context.agents.map(a => a.slug);
178
- const existing = db.prepare(`SELECT slug FROM squad_agents WHERE project_root = ?`).all(context.projectRoot);
179
- for (const row of existing) {
180
- if (!repoSlugs.includes(row.slug)) {
181
- db.prepare(`DELETE FROM squad_agents WHERE project_root = ? AND slug = ?`)
182
- .run(context.projectRoot, row.slug);
183
- }
184
- }
185
- // Upsert each agent
186
- for (const agent of context.agents) {
187
- db.prepare(`
188
- INSERT OR REPLACE INTO squad_agents
189
- (project_root, slug, role, description, charter_path, model_preference,
190
- tools_json, capabilities_json, stale, loaded_at)
191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
192
- `).run(context.projectRoot, agent.slug, agent.role, agent.description ?? null, agent.charterPath, agent.modelPreference ?? null, agent.tools ? JSON.stringify(agent.tools) : null, agent.capabilities ? JSON.stringify(agent.capabilities) : null, context.loadedAt);
193
- }
194
- }
195
- catch { /* DB errors are non-fatal — repo files are the source of truth */ }
196
- }
197
- export async function loadProjectSquad(projectPath) {
198
- try {
199
- ensureSquadTables();
200
- const db = getDb();
201
- const TTL_MS = 5 * 60 * 1000; // 5 minutes
202
- const row = db.prepare(`SELECT * FROM project_squads WHERE project_root = ?`).get(projectPath);
203
- if (row) {
204
- const loadedAt = new Date(row.loaded_at).getTime();
205
- const isStale = (Date.now() - loadedAt) > TTL_MS;
206
- const staleAgents = db.prepare(`SELECT COUNT(*) as c FROM squad_agents WHERE project_root = ? AND stale = 1`).get(projectPath);
207
- if (!isStale && staleAgents.c === 0) {
208
- // Rebuild context from DB cache
209
- const agentRows = db.prepare(`SELECT * FROM squad_agents WHERE project_root = ? AND stale = 0`).all(projectPath);
210
- const agents = agentRows.map(a => ({
211
- slug: a.slug,
212
- mention: `@${a.slug}`,
213
- role: a.role,
214
- description: a.description ?? undefined,
215
- charterPath: a.charter_path,
216
- modelPreference: a.model_preference ?? undefined,
217
- tools: a.tools_json ? JSON.parse(a.tools_json) : undefined,
218
- capabilities: a.capabilities_json
219
- ? JSON.parse(a.capabilities_json)
220
- : undefined,
221
- origin: 'project-squad',
222
- }));
223
- // Repo wins: re-sync any new agents that appeared since last cache.
224
- // Use SDK AgentsCollection.list() so we stay consistent with resolveProjectSquad().
225
- try {
226
- const cacheStorage = new FSStorageProvider();
227
- const cacheState = SquadState.fromStorage(cacheStorage, row.project_root);
228
- const repoAgentNames = await cacheState.agents.list();
229
- const cachedSlugs = new Set(agents.map(a => a.slug));
230
- for (const slug of repoAgentNames) {
231
- if (!cachedSlugs.has(slug)) {
232
- // New agent in repo not in cache — trigger full reload
233
- return resolveProjectSquad(projectPath);
234
- }
235
- }
236
- }
237
- catch { /* SDK unavailable — skip new-agent check */ }
238
- return {
239
- projectRoot: row.project_root,
240
- squadDir: row.squad_dir,
241
- teamDir: row.team_dir,
242
- personalDir: row.personal_dir,
243
- mode: row.mode,
244
- projectKey: row.project_key,
245
- configSource: row.config_source ?? undefined,
246
- config: {},
247
- agents,
248
- decisionsPath: join(row.squad_dir, 'decisions.md'),
249
- routingPath: existsSync(join(row.squad_dir, 'routing.md'))
250
- ? join(row.squad_dir, 'routing.md')
251
- : undefined,
252
- loadedAt: row.loaded_at,
253
- };
254
- }
255
- }
256
- }
257
- catch { /* DB unavailable — fall through to live resolve */ }
258
- return resolveProjectSquad(projectPath);
259
- }
260
- export function invalidateProjectSquad(projectRoot) {
261
- try {
262
- ensureSquadTables();
263
- const db = getDb();
264
- db.prepare(`UPDATE squad_agents SET stale = 1 WHERE project_root = ?`).run(projectRoot);
265
- }
266
- catch { /* non-fatal */ }
267
- }
268
- //# sourceMappingURL=discovery.js.map
@@ -1,154 +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
- // Sandbox CHAPTERHOUSE_HOME so loadProjectSquad's SQLite DB is isolated
8
- const sandboxRoot = join(repoRoot, ".test-work", `squad-discovery-${process.pid}`);
9
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
10
- async function loadDiscovery() {
11
- return await import(new URL(`./discovery.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
12
- }
13
- test.before(() => {
14
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
15
- mkdirSync(join(sandboxRoot, ".chapterhouse"), { recursive: true });
16
- });
17
- test.after(() => {
18
- rmSync(sandboxRoot, { recursive: true, force: true });
19
- });
20
- test("resolveProjectSquad returns null for path with no .squad/ directory", async () => {
21
- const m = await loadDiscovery();
22
- assert.equal(typeof m.resolveProjectSquad, "function", "resolveProjectSquad should be exported");
23
- const result = await m.resolveProjectSquad(join(repoRoot, "src/test/fixtures") // directory without .squad/
24
- );
25
- assert.equal(result, null);
26
- });
27
- test("resolveProjectSquad returns a ProjectSquadContext for the mock fixture path", async () => {
28
- const m = await loadDiscovery();
29
- const result = await m.resolveProjectSquad(fixtureRoot);
30
- assert.notEqual(result, null, "should return a context for a path with .squad/");
31
- assert.equal(typeof result, "object");
32
- });
33
- test("returned context has agents array with 2 items", async () => {
34
- const m = await loadDiscovery();
35
- const result = await m.resolveProjectSquad(fixtureRoot);
36
- assert.ok(result && Array.isArray(result.agents), "agents should be an array");
37
- assert.equal(result.agents.length, 2, "should have 2 agents (ripley, dallas)");
38
- });
39
- test("each agent descriptor has required fields with correct shape", async () => {
40
- const m = await loadDiscovery();
41
- const result = await m.resolveProjectSquad(fixtureRoot);
42
- assert.ok(result && result.agents, "context should have agents");
43
- for (const agent of result.agents) {
44
- assert.equal(typeof agent.slug, "string", `agent.slug should be a string`);
45
- assert.equal(typeof agent.mention, "string", `agent.mention should be a string`);
46
- assert.ok(agent.mention.startsWith("@"), `mention should start with @, got: ${agent.mention}`);
47
- assert.equal(typeof agent.role, "string", `agent.role should be a string`);
48
- assert.equal(typeof agent.charterPath, "string", `agent.charterPath should be a string`);
49
- assert.equal(agent.origin, "project-squad", `agent.origin should be 'project-squad'`);
50
- }
51
- });
52
- test("loadProjectSquad returns same result as resolveProjectSquad on first call (cache miss)", async () => {
53
- const m = await loadDiscovery();
54
- assert.equal(typeof m.loadProjectSquad, "function", "loadProjectSquad should be exported");
55
- // Use a unique path so we're guaranteed a cache miss
56
- const uniquePath = fixtureRoot + `-${Date.now()}`;
57
- mkdirSync(join(uniquePath, ".squad", "agents", "ripley"), { recursive: true });
58
- // Copy minimal structure
59
- const { copyFileSync } = await import("node:fs");
60
- copyFileSync(join(fixtureRoot, ".squad/agents/ripley/charter.md"), join(uniquePath, ".squad/agents/ripley/charter.md"));
61
- const resolved = await m.resolveProjectSquad(uniquePath);
62
- assert.ok(resolved !== null, "resolveProjectSquad should return non-null for valid path");
63
- const loaded = await m.loadProjectSquad(uniquePath);
64
- assert.ok(loaded !== null, "loadProjectSquad should return non-null for valid path");
65
- assert.equal(loaded.projectRoot, resolved.projectRoot, "projectRoot should match");
66
- assert.equal(loaded.agents.length, resolved.agents.length, "agent count should match");
67
- rmSync(uniquePath, { recursive: true, force: true });
68
- });
69
- test("repeated loadProjectSquad call returns cached result (same loadedAt)", async () => {
70
- const m = await loadDiscovery();
71
- const first = await m.loadProjectSquad(fixtureRoot);
72
- const second = await m.loadProjectSquad(fixtureRoot);
73
- assert.ok(first && second, "both calls should return non-null");
74
- // loadedAt is an ISO date string; on cache hit it should be identical
75
- if (first.loadedAt !== undefined && second.loadedAt !== undefined) {
76
- assert.equal(first.loadedAt, second.loadedAt, "cache hit should return same loadedAt value");
77
- }
78
- });
79
- test("invalidateProjectSquad marks agents stale — next loadProjectSquad re-resolves", async () => {
80
- const m = await loadDiscovery();
81
- assert.equal(typeof m.invalidateProjectSquad, "function", "invalidateProjectSquad should be exported");
82
- // Warm the cache
83
- const before = await m.loadProjectSquad(fixtureRoot);
84
- assert.ok(before, "should have a cached context");
85
- // Invalidate
86
- m.invalidateProjectSquad(fixtureRoot);
87
- // Re-resolve — should work and return valid context
88
- const after = await m.loadProjectSquad(fixtureRoot);
89
- assert.ok(after, "should re-resolve after invalidation");
90
- // Both should have agents (content integrity survives invalidation + re-resolve)
91
- assert.ok(after && typeof after === "object" && "agents" in after, "re-resolved context should include agents");
92
- });
93
- // ---------------------------------------------------------------------------
94
- // S5-02: agent count from filesystem is always authoritative
95
- // These tests guard the fix: GET /api/projects must count agents from disk,
96
- // not from the SQLite squad_agents cache (which is never populated on a fresh
97
- // registration, causing the badge to show 0).
98
- // ---------------------------------------------------------------------------
99
- test("S5-02: resolveProjectSquad counts 0 agents for an empty agents dir", async () => {
100
- const m = await loadDiscovery();
101
- const dir = join(repoRoot, ".test-work", `s502-empty-${process.pid}`);
102
- mkdirSync(join(dir, ".squad", "agents"), { recursive: true });
103
- try {
104
- const result = await m.resolveProjectSquad(dir);
105
- assert.ok(result !== null, "should return context even with no agents");
106
- assert.equal(result.agents.length, 0, "empty agents dir → 0 agents");
107
- }
108
- finally {
109
- rmSync(dir, { recursive: true, force: true });
110
- }
111
- });
112
- test("S5-02: resolveProjectSquad counts N agents matching charter-bearing subdirs", async () => {
113
- const m = await loadDiscovery();
114
- const dir = join(repoRoot, ".test-work", `s502-n-agents-${process.pid}`);
115
- // Create 3 agents (with charter.md) and 1 directory without charter.md
116
- for (const slug of ["alpha", "beta", "gamma"]) {
117
- mkdirSync(join(dir, ".squad", "agents", slug), { recursive: true });
118
- const { writeFileSync } = await import("node:fs");
119
- writeFileSync(join(dir, ".squad", "agents", slug, "charter.md"), `# ${slug}\n\n**Role:** Specialist\n`);
120
- }
121
- mkdirSync(join(dir, ".squad", "agents", "no-charter"), { recursive: true }); // no charter.md
122
- try {
123
- const result = await m.resolveProjectSquad(dir);
124
- assert.ok(result !== null, "should return context");
125
- assert.equal(result.agents.length, 3, "only dirs with charter.md count as agents");
126
- const slugs = result.agents.map((a) => a.slug).sort();
127
- assert.deepEqual(slugs, ["alpha", "beta", "gamma"]);
128
- }
129
- finally {
130
- rmSync(dir, { recursive: true, force: true });
131
- }
132
- });
133
- test("S5-02: resolveProjectSquad re-reads filesystem after agent added (no stale count)", async () => {
134
- const m = await loadDiscovery();
135
- const dir = join(repoRoot, ".test-work", `s502-refresh-${process.pid}`);
136
- const { writeFileSync } = await import("node:fs");
137
- // Start with 1 agent
138
- mkdirSync(join(dir, ".squad", "agents", "first"), { recursive: true });
139
- writeFileSync(join(dir, ".squad", "agents", "first", "charter.md"), "# first\n\n**Role:** Scout\n");
140
- try {
141
- const before = await m.resolveProjectSquad(dir);
142
- assert.equal(before.agents.length, 1, "before: 1 agent on disk");
143
- // Add a second agent to disk
144
- mkdirSync(join(dir, ".squad", "agents", "second"), { recursive: true });
145
- writeFileSync(join(dir, ".squad", "agents", "second", "charter.md"), "# second\n\n**Role:** Recon\n");
146
- // resolveProjectSquad always reads from disk (no TTL — direct filesystem read)
147
- const after = await m.resolveProjectSquad(dir);
148
- assert.equal(after.agents.length, 2, "after adding agent to disk: 2 agents");
149
- }
150
- finally {
151
- rmSync(dir, { recursive: true, force: true });
152
- }
153
- });
154
- //# sourceMappingURL=discovery.test.js.map
@@ -1,9 +0,0 @@
1
- export * from './types.js';
2
- export * from './discovery.js';
3
- export * from './registry.js';
4
- export * from './context.js';
5
- export * from './charter.js';
6
- export * from './mirror.js';
7
- export * from './worktree.js';
8
- export * from './init.js';
9
- //# sourceMappingURL=index.js.map