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.
- package/README.md +2 -69
- package/dist/api/server.js +8 -155
- package/dist/api/server.test.js +1 -1
- package/dist/cli.js +0 -30
- package/dist/config.js +11 -3
- package/dist/copilot/agent-event-bus.js +41 -0
- package/dist/copilot/agent-event-bus.test.js +23 -0
- package/dist/copilot/agents.js +4 -59
- package/dist/copilot/orchestrator.js +20 -39
- package/dist/copilot/orchestrator.test.js +73 -158
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/task-event-log.js +5 -5
- package/dist/copilot/task-event-log.test.js +68 -142
- package/dist/copilot/tools.js +72 -132
- package/dist/daemon.js +6 -22
- package/dist/store/db.js +2 -50
- package/dist/store/db.test.js +0 -45
- package/dist/wiki/fs.js +5 -0
- package/dist/wiki/index-manager.js +92 -17
- package/dist/wiki/index-manager.test.js +19 -0
- package/dist/wiki/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -3
- package/web/dist/assets/index-BlIWCM11.js +217 -0
- package/web/dist/assets/index-BlIWCM11.js.map +1 -0
- package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
- package/web/dist/index.html +2 -2
- package/dist/api/ralph.js +0 -153
- package/dist/api/ralph.test.js +0 -101
- package/dist/copilot/agents.squad.test.js +0 -72
- package/dist/copilot/hooks.js +0 -157
- package/dist/copilot/hooks.test.js +0 -315
- package/dist/copilot/squad-event-bus.js +0 -27
- package/dist/copilot/tools.squad.test.js +0 -168
- package/dist/squad/charter.js +0 -125
- package/dist/squad/charter.test.js +0 -89
- package/dist/squad/context.js +0 -48
- package/dist/squad/context.test.js +0 -59
- package/dist/squad/discovery.js +0 -268
- package/dist/squad/discovery.test.js +0 -154
- package/dist/squad/index.js +0 -9
- package/dist/squad/init-cli.js +0 -109
- package/dist/squad/init.js +0 -395
- package/dist/squad/init.test.js +0 -351
- package/dist/squad/mirror.js +0 -83
- package/dist/squad/mirror.scheduler.js +0 -80
- package/dist/squad/mirror.scheduler.test.js +0 -197
- package/dist/squad/mirror.test.js +0 -172
- package/dist/squad/registry.js +0 -162
- package/dist/squad/registry.test.js +0 -31
- package/dist/squad/squad-coordinator-system-message.test.js +0 -190
- package/dist/squad/squad-session-routing.test.js +0 -260
- package/dist/squad/types.js +0 -4
- package/dist/squad/worktree.js +0 -295
- package/dist/squad/worktree.test.js +0 -189
- package/dist/store/squad-sessions.test.js +0 -341
- package/web/dist/assets/index-IgSOXx_a.js +0 -219
- 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
|
package/dist/squad/context.js
DELETED
|
@@ -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
|
package/dist/squad/discovery.js
DELETED
|
@@ -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
|
package/dist/squad/index.js
DELETED
|
@@ -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
|