chapterhouse 0.1.1

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 (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,108 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ /**
3
+ * Read `context.decisionsPath`, return the last `maxChars` characters.
4
+ * Returns an empty string if the file doesn't exist or cannot be read.
5
+ */
6
+ export function readRecentDecisions(context, maxChars = 4000) {
7
+ try {
8
+ if (!existsSync(context.decisionsPath))
9
+ return '';
10
+ const content = readFileSync(context.decisionsPath, 'utf-8');
11
+ if (content.length <= maxChars)
12
+ return content;
13
+ return content.slice(content.length - maxChars);
14
+ }
15
+ catch {
16
+ return '';
17
+ }
18
+ }
19
+ /**
20
+ * Build the system prefix that is prepended to a squad agent's system prompt.
21
+ * Includes the project context, the agent's charter, and recent decisions.
22
+ */
23
+ export function buildSquadSystemPrefix(context, agent) {
24
+ let charter = '';
25
+ try {
26
+ charter = readFileSync(agent.charterPath, 'utf-8');
27
+ }
28
+ catch { /* non-fatal */ }
29
+ const decisions = readRecentDecisions(context) || '(none)';
30
+ return [
31
+ '## Squad project context',
32
+ `Project root: ${context.projectRoot}`,
33
+ `Squad dir: ${context.squadDir}`,
34
+ '',
35
+ '## Agent charter',
36
+ charter,
37
+ '',
38
+ '## Recent squad decisions',
39
+ decisions,
40
+ ].join('\n');
41
+ }
42
+ /**
43
+ * Build the delegation task prompt that is sent to a squad agent when Chapterhouse
44
+ * delegates work on behalf of the user.
45
+ */
46
+ export function buildSquadDelegationTask(context, agent, task) {
47
+ return [
48
+ `You are ${agent.mention} (${agent.role}) working on project: ${context.projectRoot}`,
49
+ '',
50
+ task,
51
+ ].join('\n');
52
+ }
53
+ /**
54
+ * Build the system message for a Squad Coordinator session attached to a specific project.
55
+ *
56
+ * Resolution order for coordinator instructions:
57
+ * 1. ${projectRoot}/.github/agents/squad.agent.md
58
+ * 2. ${projectRoot}/squad.agent.md
59
+ * Throws if neither is found.
60
+ */
61
+ export async function getSquadCoordinatorSystemMessage(projectRoot) {
62
+ // 1. Coordinator instructions
63
+ const candidatePaths = [
64
+ `${projectRoot}/.github/agents/squad.agent.md`,
65
+ `${projectRoot}/squad.agent.md`,
66
+ ];
67
+ let agentInstructions;
68
+ for (const p of candidatePaths) {
69
+ if (existsSync(p)) {
70
+ agentInstructions = readFileSync(p, 'utf-8');
71
+ break;
72
+ }
73
+ }
74
+ if (!agentInstructions) {
75
+ throw new Error(`Squad coordinator instructions not found in ${projectRoot}. ` +
76
+ `Expected .github/agents/squad.agent.md or squad.agent.md.`);
77
+ }
78
+ // 2. Project charter
79
+ const teamMdPath = `${projectRoot}/.squad/team.md`;
80
+ const teamContent = existsSync(teamMdPath)
81
+ ? readFileSync(teamMdPath, 'utf-8')
82
+ : '(not found — create .squad/team.md to provide project charter context)';
83
+ // 3. Recent decisions — last ~4000 chars
84
+ const decisionsMdPath = `${projectRoot}/.squad/decisions.md`;
85
+ let decisionsContent = '(no decisions recorded yet)';
86
+ if (existsSync(decisionsMdPath)) {
87
+ const raw = readFileSync(decisionsMdPath, 'utf-8');
88
+ decisionsContent = raw.length > 4000 ? raw.slice(raw.length - 4000) : raw;
89
+ }
90
+ return [
91
+ agentInstructions,
92
+ '',
93
+ '## Chapterhouse Project Session Context',
94
+ '',
95
+ `- **Project Root:** ${projectRoot}`,
96
+ `- **Squad Dir:** ${projectRoot}/.squad`,
97
+ `- **Session Mode:** project (Squad Coordinator)`,
98
+ '',
99
+ '## Project Charter',
100
+ '',
101
+ teamContent,
102
+ '',
103
+ '## Recent Decisions',
104
+ '',
105
+ decisionsContent,
106
+ ].join('\n');
107
+ }
108
+ //# sourceMappingURL=charter.js.map
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,280 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getDb } from '../store/db.js';
4
+ // ---------------------------------------------------------------------------
5
+ // DB schema — created lazily on first use
6
+ // ---------------------------------------------------------------------------
7
+ function ensureSquadTables() {
8
+ const db = getDb();
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS project_squads (
11
+ project_root TEXT PRIMARY KEY,
12
+ squad_dir TEXT NOT NULL,
13
+ team_dir TEXT NOT NULL,
14
+ personal_dir TEXT,
15
+ mode TEXT NOT NULL CHECK(mode IN ('local', 'remote')),
16
+ project_key TEXT,
17
+ config_source TEXT,
18
+ registered INTEGER NOT NULL DEFAULT 0,
19
+ loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
20
+ )
21
+ `);
22
+ db.exec(`
23
+ CREATE TABLE IF NOT EXISTS squad_agents (
24
+ project_root TEXT NOT NULL,
25
+ slug TEXT NOT NULL,
26
+ role TEXT NOT NULL,
27
+ description TEXT,
28
+ charter_path TEXT NOT NULL,
29
+ model_preference TEXT,
30
+ tools_json TEXT,
31
+ capabilities_json TEXT,
32
+ stale INTEGER NOT NULL DEFAULT 0,
33
+ loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ PRIMARY KEY (project_root, slug)
35
+ )
36
+ `);
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS squad_task_links (
39
+ task_id TEXT PRIMARY KEY,
40
+ project_root TEXT NOT NULL,
41
+ squad_agent_slug TEXT NOT NULL,
42
+ wiki_decision_path TEXT NOT NULL
43
+ )
44
+ `);
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Charter parsing helpers
48
+ // ---------------------------------------------------------------------------
49
+ function extractRoleFromCharter(content) {
50
+ // Look for "**Role:**" in identity section
51
+ const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/);
52
+ if (roleMatch)
53
+ return roleMatch[1].trim();
54
+ // Fallback: first heading text after "—"
55
+ const headingMatch = content.match(/^#\s+.+?—\s*(.+)/m);
56
+ if (headingMatch)
57
+ return headingMatch[1].trim();
58
+ // Second heading as role
59
+ const h2Match = content.match(/^##\s+(.+)/m);
60
+ if (h2Match)
61
+ return h2Match[1].trim();
62
+ return 'Agent';
63
+ }
64
+ function extractDescriptionFromCharter(content) {
65
+ // Blockquote after title heading
66
+ const blockquoteMatch = content.match(/^#[^\n]*\n+>\s*(.+)/m);
67
+ if (blockquoteMatch)
68
+ return blockquoteMatch[1].trim();
69
+ // First non-empty paragraph after heading
70
+ const lines = content.split('\n');
71
+ let pastHeading = false;
72
+ for (const line of lines) {
73
+ if (!pastHeading && line.startsWith('#')) {
74
+ pastHeading = true;
75
+ continue;
76
+ }
77
+ if (pastHeading && line.trim() && !line.startsWith('#') && !line.startsWith('>')) {
78
+ return line.trim();
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Core discovery — reads repo filesystem, no DB required
85
+ // ---------------------------------------------------------------------------
86
+ export async function resolveProjectSquad(projectPath) {
87
+ const squadDir = join(projectPath, '.squad');
88
+ if (!existsSync(squadDir))
89
+ return null;
90
+ // Try SDK path resolution with graceful fallback
91
+ let resolvedSquadDir = squadDir;
92
+ let mode = 'local';
93
+ let projectKey = null;
94
+ let configSource;
95
+ try {
96
+ const { resolveSquad } = await import('@bradygaster/squad-sdk');
97
+ const resolved = resolveSquad(projectPath);
98
+ if (resolved)
99
+ resolvedSquadDir = resolved;
100
+ }
101
+ catch {
102
+ // SDK not available — use manual path resolution
103
+ }
104
+ // Enumerate agents from .squad/agents/
105
+ const agentsDir = join(resolvedSquadDir, 'agents');
106
+ const agents = [];
107
+ if (existsSync(agentsDir)) {
108
+ let entries = [];
109
+ try {
110
+ entries = readdirSync(agentsDir);
111
+ }
112
+ catch { /* ignore read errors */ }
113
+ for (const entry of entries) {
114
+ const agentDir = join(agentsDir, entry);
115
+ try {
116
+ if (!statSync(agentDir).isDirectory())
117
+ continue;
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const charterPath = join(agentDir, 'charter.md');
123
+ if (!existsSync(charterPath))
124
+ continue;
125
+ let charterContent = '';
126
+ try {
127
+ charterContent = readFileSync(charterPath, 'utf-8');
128
+ }
129
+ catch {
130
+ continue;
131
+ }
132
+ const slug = entry;
133
+ const role = extractRoleFromCharter(charterContent);
134
+ const description = extractDescriptionFromCharter(charterContent);
135
+ agents.push({
136
+ slug,
137
+ mention: `@${slug}`,
138
+ role,
139
+ description,
140
+ charterPath,
141
+ origin: 'project-squad',
142
+ });
143
+ }
144
+ }
145
+ // Load squad config via SDK with fallback to empty config
146
+ let squadConfig = {};
147
+ try {
148
+ const { loadConfig } = await import('@bradygaster/squad-sdk');
149
+ const result = await loadConfig(resolvedSquadDir);
150
+ squadConfig = result.config;
151
+ configSource = result.source;
152
+ }
153
+ catch { /* SDK unavailable or config parse error — use empty config */ }
154
+ const decisionsPath = join(resolvedSquadDir, 'decisions.md');
155
+ const routingPath = join(resolvedSquadDir, 'routing.md');
156
+ const now = new Date().toISOString();
157
+ const context = {
158
+ projectRoot: projectPath,
159
+ squadDir: resolvedSquadDir,
160
+ teamDir: resolvedSquadDir,
161
+ personalDir: null,
162
+ mode,
163
+ projectKey,
164
+ configSource,
165
+ config: squadConfig,
166
+ agents,
167
+ decisionsPath,
168
+ routingPath: existsSync(routingPath) ? routingPath : undefined,
169
+ loadedAt: now,
170
+ };
171
+ // Persist to DB cache
172
+ _persistSquadToDb(context);
173
+ return context;
174
+ }
175
+ function _persistSquadToDb(context) {
176
+ try {
177
+ ensureSquadTables();
178
+ const db = getDb();
179
+ db.prepare(`
180
+ INSERT OR REPLACE INTO project_squads
181
+ (project_root, squad_dir, team_dir, personal_dir, mode, project_key, config_source, loaded_at)
182
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
183
+ `).run(context.projectRoot, context.squadDir, context.teamDir, context.personalDir ?? null, context.mode, context.projectKey ?? null, context.configSource ?? null, context.loadedAt);
184
+ // Remove agents no longer in repo
185
+ const repoSlugs = context.agents.map(a => a.slug);
186
+ const existing = db.prepare(`SELECT slug FROM squad_agents WHERE project_root = ?`).all(context.projectRoot);
187
+ for (const row of existing) {
188
+ if (!repoSlugs.includes(row.slug)) {
189
+ db.prepare(`DELETE FROM squad_agents WHERE project_root = ? AND slug = ?`)
190
+ .run(context.projectRoot, row.slug);
191
+ }
192
+ }
193
+ // Upsert each agent
194
+ for (const agent of context.agents) {
195
+ db.prepare(`
196
+ INSERT OR REPLACE INTO squad_agents
197
+ (project_root, slug, role, description, charter_path, model_preference,
198
+ tools_json, capabilities_json, stale, loaded_at)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
200
+ `).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);
201
+ }
202
+ }
203
+ catch { /* DB errors are non-fatal — repo files are the source of truth */ }
204
+ }
205
+ export async function loadProjectSquad(projectPath) {
206
+ try {
207
+ ensureSquadTables();
208
+ const db = getDb();
209
+ const TTL_MS = 5 * 60 * 1000; // 5 minutes
210
+ const row = db.prepare(`SELECT * FROM project_squads WHERE project_root = ?`).get(projectPath);
211
+ if (row) {
212
+ const loadedAt = new Date(row.loaded_at).getTime();
213
+ const isStale = (Date.now() - loadedAt) > TTL_MS;
214
+ const staleAgents = db.prepare(`SELECT COUNT(*) as c FROM squad_agents WHERE project_root = ? AND stale = 1`).get(projectPath);
215
+ if (!isStale && staleAgents.c === 0) {
216
+ // Rebuild context from DB cache
217
+ const agentRows = db.prepare(`SELECT * FROM squad_agents WHERE project_root = ? AND stale = 0`).all(projectPath);
218
+ const agents = agentRows.map(a => ({
219
+ slug: a.slug,
220
+ mention: `@${a.slug}`,
221
+ role: a.role,
222
+ description: a.description ?? undefined,
223
+ charterPath: a.charter_path,
224
+ modelPreference: a.model_preference ?? undefined,
225
+ tools: a.tools_json ? JSON.parse(a.tools_json) : undefined,
226
+ capabilities: a.capabilities_json
227
+ ? JSON.parse(a.capabilities_json)
228
+ : undefined,
229
+ origin: 'project-squad',
230
+ }));
231
+ // Repo wins: re-sync any new agents that appeared since last cache
232
+ const repoAgentsDir = join(row.squad_dir, 'agents');
233
+ if (existsSync(repoAgentsDir)) {
234
+ const repoDirs = readdirSync(repoAgentsDir).filter(e => {
235
+ try {
236
+ return statSync(join(repoAgentsDir, e)).isDirectory();
237
+ }
238
+ catch {
239
+ return false;
240
+ }
241
+ });
242
+ const cachedSlugs = new Set(agents.map(a => a.slug));
243
+ for (const slug of repoDirs) {
244
+ if (!cachedSlugs.has(slug)) {
245
+ // New agent in repo not in cache — trigger full reload
246
+ return resolveProjectSquad(projectPath);
247
+ }
248
+ }
249
+ }
250
+ return {
251
+ projectRoot: row.project_root,
252
+ squadDir: row.squad_dir,
253
+ teamDir: row.team_dir,
254
+ personalDir: row.personal_dir,
255
+ mode: row.mode,
256
+ projectKey: row.project_key,
257
+ configSource: row.config_source ?? undefined,
258
+ config: {},
259
+ agents,
260
+ decisionsPath: join(row.squad_dir, 'decisions.md'),
261
+ routingPath: existsSync(join(row.squad_dir, 'routing.md'))
262
+ ? join(row.squad_dir, 'routing.md')
263
+ : undefined,
264
+ loadedAt: row.loaded_at,
265
+ };
266
+ }
267
+ }
268
+ }
269
+ catch { /* DB unavailable — fall through to live resolve */ }
270
+ return resolveProjectSquad(projectPath);
271
+ }
272
+ export function invalidateProjectSquad(projectRoot) {
273
+ try {
274
+ ensureSquadTables();
275
+ const db = getDb();
276
+ db.prepare(`UPDATE squad_agents SET stale = 1 WHERE project_root = ?`).run(projectRoot);
277
+ }
278
+ catch { /* non-fatal */ }
279
+ }
280
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,93 @@
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
+ //# sourceMappingURL=discovery.test.js.map