chapterhouse 0.1.1 → 0.1.5

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.
@@ -1,7 +1,7 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import helmet from "helmet";
4
- import { existsSync, statSync } from "fs";
4
+ import { existsSync, statSync, readdirSync } from "fs";
5
5
  import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
@@ -23,6 +23,7 @@ import { getDb } from "../store/db.js";
23
23
  import { getStatus, onStatusChange } from "../status.js";
24
24
  import { formatSseData, formatSseEvent } from "./sse.js";
25
25
  import { syncDecisionsFileToWiki } from "../squad/mirror.js";
26
+ import { resolveProjectSquad } from "../squad/discovery.js";
26
27
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
27
28
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
28
29
  void searchIndex; // re-exported by index-manager; reference here documents the dep
@@ -528,6 +529,31 @@ app.post("/api/restart", (_req, res) => {
528
529
  const projectRegisterSchema = z.object({
529
530
  projectRoot: requiredString("projectRoot must be a non-empty string"),
530
531
  }).strict();
532
+ /**
533
+ * Count squad agents on disk for a project.
534
+ * Authoritative source: each subdirectory of <projectRoot>/.squad/agents/ that
535
+ * contains a charter.md is one agent. Never relies on the SQLite cache so the
536
+ * badge is always accurate even before the cache is warm.
537
+ */
538
+ function countAgentsOnDisk(projectRoot) {
539
+ const agentsDir = join(projectRoot, ".squad", "agents");
540
+ if (!existsSync(agentsDir))
541
+ return 0;
542
+ try {
543
+ return readdirSync(agentsDir).filter((entry) => {
544
+ try {
545
+ return statSync(join(agentsDir, entry)).isDirectory() &&
546
+ existsSync(join(agentsDir, entry, "charter.md"));
547
+ }
548
+ catch {
549
+ return false;
550
+ }
551
+ }).length;
552
+ }
553
+ catch {
554
+ return 0;
555
+ }
556
+ }
531
557
  app.get("/api/projects", (_req, res) => {
532
558
  if (!config.squadEnabled) {
533
559
  res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
@@ -535,18 +561,16 @@ app.get("/api/projects", (_req, res) => {
535
561
  }
536
562
  const db = getDb();
537
563
  const rows = db.prepare(`
538
- SELECT project_squads.project_root, project_squads.squad_dir,
539
- COUNT(squad_agents.slug) as agent_count, project_squads.loaded_at
564
+ SELECT project_root, squad_dir, loaded_at
540
565
  FROM project_squads
541
- LEFT JOIN squad_agents ON project_squads.project_root = squad_agents.project_root
542
- WHERE project_squads.registered = 1
543
- GROUP BY project_squads.project_root
544
- ORDER BY project_squads.loaded_at DESC
566
+ WHERE registered = 1
567
+ ORDER BY loaded_at DESC
545
568
  `).all();
546
569
  res.json(rows.map((r) => ({
547
570
  projectRoot: r.project_root,
548
571
  squadDir: r.squad_dir,
549
- agentCount: r.agent_count,
572
+ // Count from live filesystem — authoritative per Squad SDK rule: repo files win over cache.
573
+ agentCount: countAgentsOnDisk(r.project_root),
550
574
  loadedAt: r.loaded_at,
551
575
  })));
552
576
  });
@@ -575,6 +599,11 @@ app.post("/api/projects", async (req, res) => {
575
599
  }).catch(err => {
576
600
  console.warn('[squad] syncDecisionsFileToWiki failed during registration (non-fatal):', err instanceof Error ? err.message : err);
577
601
  });
602
+ // Fire-and-forget: populate squad_agents cache from disk so future queries have
603
+ // something to work with (non-fatal — GET /api/projects counts live from disk anyway).
604
+ resolveProjectSquad(projectRoot).catch(err => {
605
+ console.warn('[squad] resolveProjectSquad failed during registration (non-fatal):', err instanceof Error ? err.message : err);
606
+ });
578
607
  res.status(201).json({ projectRoot, message: "Project registered successfully" });
579
608
  });
580
609
  app.delete("/api/projects/:projectRoot", (req, res) => {
@@ -5,7 +5,7 @@ import { config, DEFAULT_MODEL } from "../config.js";
5
5
  import { loadMcpConfig } from "./mcp-config.js";
6
6
  import { getSkillDirectories } from "./skills.js";
7
7
  import { resetClient } from "./client.js";
8
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey } from "../store/db.js";
8
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb } from "../store/db.js";
9
9
  import { maybeWriteEpisode } from "./episode-writer.js";
10
10
  import { getWikiSummary } from "../wiki/context.js";
11
11
  import { SESSIONS_DIR } from "../paths.js";
@@ -403,6 +403,32 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
403
403
  });
404
404
  })
405
405
  : () => { };
406
+ // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
407
+ // These fire when the built-in `task` tool (Squad coordinator) routes work to a
408
+ // specialist — separate from `delegate_to_agent` which handles CH-registry agents.
409
+ const db = getDb();
410
+ const unsubSubStartDb = session.on("subagent.started", (event) => {
411
+ try {
412
+ const data = event.data;
413
+ const agentSlug = (data.agentName || "unknown").toLowerCase().replace(/\s+/g, "-");
414
+ const description = (data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
415
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, currentSourceChannel || null, sessionKey);
416
+ }
417
+ catch { /* non-fatal */ }
418
+ });
419
+ const unsubSubDoneDb = session.on("subagent.completed", (event) => {
420
+ try {
421
+ db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
422
+ }
423
+ catch { /* non-fatal */ }
424
+ });
425
+ const unsubSubFailDb = session.on("subagent.failed", (event) => {
426
+ try {
427
+ const data = event.data;
428
+ db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
429
+ }
430
+ catch { /* non-fatal */ }
431
+ });
406
432
  const unsubDelta = session.on("assistant.message_delta", (event) => {
407
433
  // After a tool call completes, ensure a line break separates the text blocks
408
434
  // so they don't visually run together in the rendered chat.
@@ -455,6 +481,9 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
455
481
  unsubSubStart();
456
482
  unsubSubDone();
457
483
  unsubSubFail();
484
+ unsubSubStartDb();
485
+ unsubSubDoneDb();
486
+ unsubSubFailDb();
458
487
  currentCallback = undefined;
459
488
  currentActivityCallback = undefined;
460
489
  currentProcessingSessionKey = undefined;
@@ -13,6 +13,11 @@ function createFakeClient(state) {
13
13
  this.listeners.set(eventName, next);
14
14
  };
15
15
  }
16
+ emit(eventName, data) {
17
+ for (const handler of this.listeners.get(eventName) || []) {
18
+ handler({ data });
19
+ }
20
+ }
16
21
  async sendAndWait(request, _timeoutMs) {
17
22
  state.sessionPrompts.push(request);
18
23
  if (state.sendResult === "__PENDING__") {
@@ -45,7 +50,11 @@ function createFakeClient(state) {
45
50
  if (state.createSessionError) {
46
51
  throw new Error(state.createSessionError);
47
52
  }
48
- return new FakeSession();
53
+ const session = new FakeSession();
54
+ state.lastSession = {
55
+ emit: (eventName, data) => session.emit(eventName, data),
56
+ };
57
+ return session;
49
58
  },
50
59
  async resumeSession(savedId, options) {
51
60
  state.resumeSessionCalls.push({ savedId, options });
@@ -71,6 +80,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
71
80
  disconnectCalls: 0,
72
81
  conversationLogs: [],
73
82
  dbLogs: [],
83
+ dbWrites: [],
74
84
  episodeWrites: 0,
75
85
  ensureDefaultAgentsCalls: 0,
76
86
  loadAgentsCalls: 0,
@@ -162,6 +172,16 @@ async function loadOrchestratorModule(t, overrides = {}) {
162
172
  getCopilotSession: (_sessionKey) => undefined,
163
173
  upsertCopilotSession: (_sessionKey, _mode, _copilotSessionId, _projectRoot, _model) => { },
164
174
  getTaskSessionKey: (taskId) => state.taskSessionKeys?.get(taskId) ?? "default",
175
+ getDb: () => ({
176
+ prepare: (sql) => ({
177
+ run: (...args) => {
178
+ state.dbWrites.push({ sql, args });
179
+ return {};
180
+ },
181
+ get: () => undefined,
182
+ all: () => [],
183
+ }),
184
+ }),
165
185
  },
166
186
  });
167
187
  t.mock.module("./episode-writer.js", {
@@ -520,4 +540,89 @@ test("ensureOrchestratorSession cleans up in-flight promise on session creation
520
540
  await new Promise((resolve) => setTimeout(resolve, 20));
521
541
  assert.ok(state.createSessionCalls.length > countAfterFirst, "a second message must trigger a new createSession attempt, proving sessionCreatePromises was cleaned up");
522
542
  });
543
+ // ---------------------------------------------------------------------------
544
+ // S5-01: SDK subagent events persist to agent_tasks (squad dispatch tracking)
545
+ // Root cause: executeOnSession subscribed to subagent.* events for the UI
546
+ // activity feed but never wrote rows to agent_tasks. Workers tab only reads
547
+ // agent_tasks, so squad coordinator dispatches were invisible.
548
+ // Fix: unconditional DB subscriptions in executeOnSession write/update rows.
549
+ // ---------------------------------------------------------------------------
550
+ test("S5-01: subagent.started event inserts a squad row into agent_tasks", async (t) => {
551
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
552
+ sendResult: "__PENDING__",
553
+ });
554
+ await orchestrator.initOrchestrator(client);
555
+ // Start a message so executeOnSession runs and registers event handlers
556
+ const callbackResults = [];
557
+ orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, (text, done) => {
558
+ callbackResults.push({ text, done });
559
+ });
560
+ // Yield so the async IIFE reaches sendAndWait (which is pending)
561
+ await new Promise((resolve) => setTimeout(resolve, 10));
562
+ assert.ok(state.lastSession, "FakeSession should have been created");
563
+ // Fire a subagent.started event — simulates the SDK's task tool starting a squad dispatch
564
+ state.lastSession.emit("subagent.started", {
565
+ toolCallId: "subagent-call-001",
566
+ agentName: "Kaylee",
567
+ agentDisplayName: "Kaylee — Backend Dev",
568
+ agentDescription: "Implement S5-01 backend fix",
569
+ });
570
+ // The handler is synchronous — DB write should be in state.dbWrites immediately
571
+ const insertWrite = state.dbWrites.find((w) => w.sql.includes("INSERT") && w.sql.includes("agent_tasks"));
572
+ assert.ok(insertWrite, "subagent.started must INSERT a row into agent_tasks");
573
+ assert.ok((insertWrite.sql + JSON.stringify(insertWrite.args)).includes("squad"), "inserted row must carry source='squad'");
574
+ assert.ok(JSON.stringify(insertWrite.args).includes("subagent-call-001"), "task_id must equal the toolCallId from the event");
575
+ // Resolve the pending sendAndWait so the test can clean up
576
+ state.pendingReject?.(new Error("test teardown"));
577
+ });
578
+ test("S5-01: subagent.completed event updates agent_tasks status to completed", async (t) => {
579
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
580
+ sendResult: "__PENDING__",
581
+ });
582
+ await orchestrator.initOrchestrator(client);
583
+ orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, () => { });
584
+ await new Promise((resolve) => setTimeout(resolve, 10));
585
+ // First, start the subagent
586
+ state.lastSession.emit("subagent.started", {
587
+ toolCallId: "subagent-call-002",
588
+ agentName: "Wash",
589
+ agentDisplayName: "Wash — Frontend Dev",
590
+ agentDescription: "Fix Workers tab UI",
591
+ });
592
+ // Then complete it
593
+ state.lastSession.emit("subagent.completed", {
594
+ toolCallId: "subagent-call-002",
595
+ agentName: "Wash",
596
+ agentDisplayName: "Wash — Frontend Dev",
597
+ durationMs: 1234,
598
+ });
599
+ const updateWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("completed"));
600
+ assert.ok(updateWrite, "subagent.completed must UPDATE agent_tasks to completed");
601
+ assert.ok(JSON.stringify(updateWrite.args).includes("subagent-call-002"), "UPDATE must target the correct task_id");
602
+ state.pendingReject?.(new Error("test teardown"));
603
+ });
604
+ test("S5-01: subagent.failed event updates agent_tasks status to error", async (t) => {
605
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
606
+ sendResult: "__PENDING__",
607
+ });
608
+ await orchestrator.initOrchestrator(client);
609
+ orchestrator.sendToOrchestrator("dispatch something", { type: "background" }, () => { });
610
+ await new Promise((resolve) => setTimeout(resolve, 10));
611
+ state.lastSession.emit("subagent.started", {
612
+ toolCallId: "subagent-call-003",
613
+ agentName: "Zoe",
614
+ agentDisplayName: "Zoe — QA",
615
+ agentDescription: "Run test suite",
616
+ });
617
+ state.lastSession.emit("subagent.failed", {
618
+ toolCallId: "subagent-call-003",
619
+ agentName: "Zoe",
620
+ agentDisplayName: "Zoe — QA",
621
+ error: "Timeout after 600s",
622
+ });
623
+ const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
624
+ assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
625
+ assert.ok(JSON.stringify(errorWrite.args).includes("subagent-call-003"), "UPDATE must target the correct task_id");
626
+ state.pendingReject?.(new Error("test teardown"));
627
+ });
523
628
  //# sourceMappingURL=orchestrator.test.js.map
@@ -156,7 +156,7 @@ export function createTools(deps) {
156
156
  const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel());
157
157
  // Persist task to DB
158
158
  const db = getDb();
159
- db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key) VALUES (?, ?, ?, 'running', ?, ?)`).run(task.taskId, delegatedSlug, args.summary, task.originChannel || null, getCurrentSessionKey());
159
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, task.originChannel || null, getCurrentSessionKey());
160
160
  // Capture the parent's activity callback so the child session can stream
161
161
  // its events back to the originating SSE connection. This survives past
162
162
  // the parent assistant turn — the child runs long after the parent's
@@ -90,4 +90,65 @@ test("invalidateProjectSquad marks agents stale — next loadProjectSquad re-res
90
90
  // Both should have agents (content integrity survives invalidation + re-resolve)
91
91
  assert.ok(after && typeof after === "object" && "agents" in after, "re-resolved context should include agents");
92
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
+ });
93
154
  //# sourceMappingURL=discovery.test.js.map
package/dist/store/db.js CHANGED
@@ -146,6 +146,10 @@ export function getDb() {
146
146
  if (!taskCols.some((c) => c.name === 'session_key')) {
147
147
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
148
148
  }
149
+ // Migrate: add source column to agent_tasks ('adhoc' | 'squad') if not present
150
+ if (!taskCols.some((c) => c.name === 'source')) {
151
+ db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
152
+ }
149
153
  // Prune conversation log at startup — keep more history for better recovery
150
154
  db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
151
155
  // Set up FTS5 for memory search (graceful fallback if not available)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"
@@ -4,7 +4,7 @@
4
4
  width="512px" height="512px" viewBox="0 0 1024.000000 1024.000000"
5
5
  preserveAspectRatio="xMidYMid meet">
6
6
  <g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
7
- fill="#000000" stroke="none">
7
+ fill="#CD7F32" stroke="none">
8
8
  <path d="M4960 9676 c-415 -143 -684 -258 -935 -401 -530 -300 -969 -739
9
9
  -1277 -1275 -48 -83 -186 -364 -231 -471 -214 -503 -382 -1155 -491 -1909 -25
10
10
  -172 -73 -606 -101 -909 -18 -192 -32 -297 -43 -325 -26 -62 -23 -283 6 -397