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.
- package/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- 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
|