chapterhouse 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +8 -155
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/cli.js +0 -30
  5. package/dist/config.js +11 -3
  6. package/dist/copilot/agent-event-bus.js +41 -0
  7. package/dist/copilot/agent-event-bus.test.js +23 -0
  8. package/dist/copilot/agents.js +4 -59
  9. package/dist/copilot/orchestrator.js +20 -39
  10. package/dist/copilot/orchestrator.test.js +73 -158
  11. package/dist/copilot/system-message.js +7 -0
  12. package/dist/copilot/task-event-log.js +5 -5
  13. package/dist/copilot/task-event-log.test.js +68 -142
  14. package/dist/copilot/tools.js +72 -132
  15. package/dist/daemon.js +6 -22
  16. package/dist/store/db.js +2 -50
  17. package/dist/store/db.test.js +0 -45
  18. package/dist/wiki/fs.js +5 -0
  19. package/dist/wiki/index-manager.js +92 -17
  20. package/dist/wiki/index-manager.test.js +19 -0
  21. package/dist/wiki/migrate-topics.js +132 -0
  22. package/dist/wiki/migrate-topics.test.js +57 -0
  23. package/dist/wiki/topic-structure.js +167 -0
  24. package/dist/wiki/topic-structure.test.js +74 -0
  25. package/package.json +1 -3
  26. package/web/dist/assets/index-BlIWCM11.js +217 -0
  27. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  28. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  29. package/web/dist/index.html +2 -2
  30. package/dist/api/ralph.js +0 -153
  31. package/dist/api/ralph.test.js +0 -101
  32. package/dist/copilot/agents.squad.test.js +0 -72
  33. package/dist/copilot/hooks.js +0 -157
  34. package/dist/copilot/hooks.test.js +0 -315
  35. package/dist/copilot/squad-event-bus.js +0 -27
  36. package/dist/copilot/tools.squad.test.js +0 -168
  37. package/dist/squad/charter.js +0 -125
  38. package/dist/squad/charter.test.js +0 -89
  39. package/dist/squad/context.js +0 -48
  40. package/dist/squad/context.test.js +0 -59
  41. package/dist/squad/discovery.js +0 -268
  42. package/dist/squad/discovery.test.js +0 -154
  43. package/dist/squad/index.js +0 -9
  44. package/dist/squad/init-cli.js +0 -109
  45. package/dist/squad/init.js +0 -395
  46. package/dist/squad/init.test.js +0 -351
  47. package/dist/squad/mirror.js +0 -83
  48. package/dist/squad/mirror.scheduler.js +0 -80
  49. package/dist/squad/mirror.scheduler.test.js +0 -197
  50. package/dist/squad/mirror.test.js +0 -172
  51. package/dist/squad/registry.js +0 -162
  52. package/dist/squad/registry.test.js +0 -31
  53. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  54. package/dist/squad/squad-session-routing.test.js +0 -260
  55. package/dist/squad/types.js +0 -4
  56. package/dist/squad/worktree.js +0 -295
  57. package/dist/squad/worktree.test.js +0 -189
  58. package/dist/store/squad-sessions.test.js +0 -341
  59. package/web/dist/assets/index-IgSOXx_a.js +0 -219
  60. package/web/dist/assets/index-IgSOXx_a.js.map +0 -1
@@ -14,13 +14,13 @@
14
14
  * 10. Multiple tasks tracked independently
15
15
  * 11. RING_BUFFER_CAPACITY constant: 500 items, evicts oldest
16
16
  *
17
- * Uses node:test (same pattern as hooks.test.ts, agents.squad.test.ts) so the
17
+ * Uses node:test (same pattern as hooks.test.ts) so the
18
18
  * daemon's `npm test` runner (node --test) picks these up from dist/.
19
19
  */
20
20
  import { describe, it, beforeEach, afterEach } from "node:test";
21
21
  import assert from "node:assert/strict";
22
- import { EventBus } from "@bradygaster/squad-sdk/runtime/event-bus";
23
- import { RingBuffer, RING_BUFFER_CAPACITY, } from "./task-event-log.js";
22
+ import { agentEventBus } from "./agent-event-bus.js";
23
+ import { RingBuffer, RING_BUFFER_CAPACITY, initTaskEventLog, getTaskLogEvents, subscribeTaskLog, clearTaskLog, taskLogSize, } from "./task-event-log.js";
24
24
  // ---------------------------------------------------------------------------
25
25
  // Helpers
26
26
  // ---------------------------------------------------------------------------
@@ -48,87 +48,6 @@ function makeDestroyedEvent(sessionId) {
48
48
  timestamp: new Date(),
49
49
  };
50
50
  }
51
- /**
52
- * Builds an isolated task-event-log wired to a fresh EventBus so tests don't
53
- * share state through the process-level squadEventBus singleton.
54
- */
55
- function buildIsolatedLog(bus) {
56
- const CAPACITY = RING_BUFFER_CAPACITY;
57
- const buffers = new Map();
58
- const listeners = new Map();
59
- function getOrCreate(taskId) {
60
- let b = buffers.get(taskId);
61
- if (!b) {
62
- b = new RingBuffer(CAPACITY);
63
- buffers.set(taskId, b);
64
- }
65
- return b;
66
- }
67
- function notify(taskId, event) {
68
- const ls = listeners.get(taskId);
69
- if (!ls)
70
- return;
71
- for (const fn of ls) {
72
- try {
73
- fn(event);
74
- }
75
- catch { /* non-fatal */ }
76
- }
77
- }
78
- const unsubToolCall = bus.subscribe("session:tool_call", (event) => {
79
- const taskId = event.sessionId;
80
- if (!taskId)
81
- return;
82
- const p = event.payload;
83
- const ev = {
84
- id: 0, taskId,
85
- seq: p._seq ?? 0,
86
- ts: p._ts ?? Date.now(),
87
- kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
88
- toolName: p.toolName ?? null,
89
- summary: p._summary ?? null,
90
- };
91
- getOrCreate(taskId).push(ev);
92
- notify(taskId, ev);
93
- });
94
- const unsubDestroyed = bus.subscribe("session:destroyed", (event) => {
95
- if (event.sessionId) {
96
- buffers.delete(event.sessionId);
97
- listeners.delete(event.sessionId);
98
- }
99
- });
100
- return {
101
- getEvents: (taskId, afterSeq = 0) => {
102
- const buf = buffers.get(taskId);
103
- if (!buf)
104
- return [];
105
- const all = buf.getAll();
106
- return afterSeq === 0 ? all : all.filter((e) => e.seq > afterSeq);
107
- },
108
- subscribe: (taskId, fn) => {
109
- let ls = listeners.get(taskId);
110
- if (!ls) {
111
- ls = new Set();
112
- listeners.set(taskId, ls);
113
- }
114
- ls.add(fn);
115
- return () => {
116
- const lss = listeners.get(taskId);
117
- if (lss) {
118
- lss.delete(fn);
119
- if (lss.size === 0)
120
- listeners.delete(taskId);
121
- }
122
- };
123
- },
124
- clear: (taskId) => {
125
- buffers.delete(taskId);
126
- listeners.delete(taskId);
127
- },
128
- size: () => buffers.size,
129
- shutdown: () => { unsubToolCall(); unsubDestroyed(); },
130
- };
131
- }
132
51
  // ---------------------------------------------------------------------------
133
52
  // RingBuffer — pure class tests
134
53
  // ---------------------------------------------------------------------------
@@ -189,87 +108,94 @@ describe("RingBuffer", () => {
189
108
  // Task event log (bus-wired) tests
190
109
  // ---------------------------------------------------------------------------
191
110
  describe("task event log — bus-wired", () => {
192
- let bus;
193
- let log;
111
+ let shutdown;
194
112
  beforeEach(() => {
195
- bus = new EventBus();
196
- log = buildIsolatedLog(bus);
113
+ shutdown = initTaskEventLog();
197
114
  });
198
115
  afterEach(() => {
199
- log.shutdown();
116
+ shutdown();
200
117
  });
201
118
  it("returns [] for an unknown taskId", () => {
202
- assert.deepEqual(log.getEvents("no-such-task"), []);
119
+ assert.deepEqual(getTaskLogEvents("no-such-task"), []);
203
120
  });
204
- it("populates ring buffer on session:tool_call bus event", async () => {
205
- await bus.emit(makeToolCallEvent({ sessionId: "task-001", kind: "tool_start", seq: 1, toolName: "view" }));
206
- const events = log.getEvents("task-001");
121
+ it("populates ring buffer on session:tool_call bus event", () => {
122
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-001", kind: "tool_start", seq: 1, toolName: "view" }));
123
+ const events = getTaskLogEvents("task-001");
207
124
  assert.equal(events.length, 1);
208
125
  assert.equal(events[0].kind, "tool_start");
209
126
  assert.equal(events[0].toolName, "view");
210
127
  assert.equal(events[0].taskId, "task-001");
211
128
  assert.equal(events[0].seq, 1);
129
+ clearTaskLog("task-001");
212
130
  });
213
- it("accumulates multiple events in order", async () => {
214
- await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
215
- await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));
216
- await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 3, toolName: "view" }));
217
- const events = log.getEvents("task-002");
131
+ it("accumulates multiple events in order", () => {
132
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
133
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));
134
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 3, toolName: "view" }));
135
+ const events = getTaskLogEvents("task-002");
218
136
  assert.equal(events.length, 3);
219
137
  assert.equal(events[0].seq, 1);
220
138
  assert.equal(events[2].seq, 3);
221
139
  assert.equal(events[1].kind, "tool_complete");
222
- });
223
- it("getEvents filters by afterSeq", async () => {
224
- await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 1 }));
225
- await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 2 }));
226
- await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 3 }));
227
- assert.equal(log.getEvents("task-003", 1).length, 2);
228
- assert.equal(log.getEvents("task-003", 2).length, 1);
229
- assert.equal(log.getEvents("task-003", 3).length, 0);
230
- });
231
- it("subscribe delivers events as they arrive", async () => {
140
+ clearTaskLog("task-002");
141
+ });
142
+ it("getEvents filters by afterSeq", () => {
143
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 1 }));
144
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 2 }));
145
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 3 }));
146
+ assert.equal(getTaskLogEvents("task-003", 1).length, 2);
147
+ assert.equal(getTaskLogEvents("task-003", 2).length, 1);
148
+ assert.equal(getTaskLogEvents("task-003", 3).length, 0);
149
+ clearTaskLog("task-003");
150
+ });
151
+ it("subscribe delivers events as they arrive", () => {
232
152
  const received = [];
233
- log.subscribe("task-004", (e) => received.push(e.toolName ?? "?"));
234
- await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "bash" }));
235
- await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "view" }));
153
+ const unsub = subscribeTaskLog("task-004", (e) => received.push(e.toolName ?? "?"));
154
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "bash" }));
155
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "view" }));
156
+ unsub();
236
157
  assert.deepEqual(received, ["bash", "view"]);
158
+ clearTaskLog("task-004");
237
159
  });
238
- it("subscribe unsub stops delivery", async () => {
160
+ it("subscribe unsub stops delivery", () => {
239
161
  const received = [];
240
- const unsub = log.subscribe("task-005", (e) => received.push(e.toolName ?? "?"));
241
- await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "bash" }));
162
+ const unsub = subscribeTaskLog("task-005", (e) => received.push(e.toolName ?? "?"));
163
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "bash" }));
242
164
  unsub();
243
- await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "view" }));
165
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "view" }));
244
166
  assert.equal(received.length, 1);
245
167
  assert.equal(received[0], "bash");
246
- });
247
- it("clearTaskLog removes buffer and listeners", async () => {
248
- await bus.emit(makeToolCallEvent({ sessionId: "task-006", seq: 1 }));
249
- assert.equal(log.getEvents("task-006").length, 1);
250
- log.clear("task-006");
251
- assert.equal(log.getEvents("task-006").length, 0);
252
- });
253
- it("session:destroyed auto-clears ring buffer", async () => {
254
- await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 1 }));
255
- await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 2 }));
256
- assert.equal(log.getEvents("task-007").length, 2);
257
- await bus.emit(makeDestroyedEvent("task-007"));
258
- assert.equal(log.getEvents("task-007").length, 0);
259
- });
260
- it("tracks multiple tasks independently", async () => {
261
- await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 1, toolName: "bash" }));
262
- await bus.emit(makeToolCallEvent({ sessionId: "task-B", seq: 1, toolName: "view" }));
263
- await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 2, toolName: "edit" }));
264
- assert.equal(log.getEvents("task-A").length, 2);
265
- assert.equal(log.getEvents("task-B").length, 1);
266
- assert.equal(log.getEvents("task-A")[0].toolName, "bash");
267
- assert.equal(log.getEvents("task-B")[0].toolName, "view");
268
- assert.equal(log.size(), 2);
269
- });
270
- it("events for wrong taskId are not visible to other tasks", async () => {
271
- await bus.emit(makeToolCallEvent({ sessionId: "task-X", seq: 1 }));
272
- assert.equal(log.getEvents("task-Y").length, 0);
168
+ clearTaskLog("task-005");
169
+ });
170
+ it("clearTaskLog removes buffer and listeners", () => {
171
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-006", seq: 1 }));
172
+ assert.equal(getTaskLogEvents("task-006").length, 1);
173
+ clearTaskLog("task-006");
174
+ assert.equal(getTaskLogEvents("task-006").length, 0);
175
+ });
176
+ it("session:destroyed auto-clears ring buffer", () => {
177
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 1 }));
178
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 2 }));
179
+ assert.equal(getTaskLogEvents("task-007").length, 2);
180
+ agentEventBus.emit(makeDestroyedEvent("task-007"));
181
+ assert.equal(getTaskLogEvents("task-007").length, 0);
182
+ });
183
+ it("tracks multiple tasks independently", () => {
184
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 1, toolName: "bash" }));
185
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-B", seq: 1, toolName: "view" }));
186
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 2, toolName: "edit" }));
187
+ assert.equal(getTaskLogEvents("task-A").length, 2);
188
+ assert.equal(getTaskLogEvents("task-B").length, 1);
189
+ assert.equal(getTaskLogEvents("task-A")[0].toolName, "bash");
190
+ assert.equal(getTaskLogEvents("task-B")[0].toolName, "view");
191
+ assert.equal(taskLogSize(), 2);
192
+ clearTaskLog("task-A");
193
+ clearTaskLog("task-B");
194
+ });
195
+ it("events for wrong taskId are not visible to other tasks", () => {
196
+ agentEventBus.emit(makeToolCallEvent({ sessionId: "task-X", seq: 1 }));
197
+ assert.equal(getTaskLogEvents("task-Y").length, 0);
198
+ clearTaskLog("task-X");
273
199
  });
274
200
  });
275
201
  //# sourceMappingURL=task-event-log.test.js.map
@@ -6,36 +6,21 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
- import { createSessionHooks } from "./hooks.js";
10
- import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentChannelKey, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
9
+ import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
11
10
  import { getRouterConfig, updateRouterConfig } from "./router.js";
12
11
  import { ensureWikiStructure, readPage, writePage, deletePage, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
13
12
  import { searchIndex, addToIndex, removeFromIndex, parseIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
14
13
  import { appendLog } from "../wiki/log-manager.js";
14
+ import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
15
15
  import { withWikiWrite } from "../wiki/lock.js";
16
16
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
17
- import { getAgentRegistry, getAgent, createEphemeralAgentSession, createSquadAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
17
+ import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
18
18
  import { adoGetOkrs, adoOkrSummary, adoUpdateKr } from "../integrations/ado-skill.js";
19
19
  import { TeamsNotifier } from "../integrations/teams-notify.js";
20
20
  import { TeamPushClient } from "../integrations/team-push.js";
21
21
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
22
- import { getChannelProject } from "../squad/context.js";
23
- import { findSquadAgent } from "../squad/registry.js";
24
- import { buildSquadSystemPrefix } from "../squad/charter.js";
25
- import { mirrorDecisionToWiki, syncDecisionsFileToWiki } from "../squad/mirror.js";
26
22
  import { childLogger } from "../util/logger.js";
27
23
  const log = childLogger("tools");
28
- function getCategoryDir(category) {
29
- const map = {
30
- person: "people",
31
- project: "projects",
32
- preference: "preferences",
33
- fact: "facts",
34
- routine: "routines",
35
- decision: "decisions",
36
- };
37
- return map[category] || category;
38
- }
39
24
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
40
25
  function yamlEscape(value) {
41
26
  // Always quote and escape backslashes, double quotes, and newlines.
@@ -112,45 +97,15 @@ export function createTools(deps) {
112
97
  if (agent?.slug === "chapterhouse") {
113
98
  return "Cannot delegate to yourself. Handle this directly or pick a specialist agent.";
114
99
  }
115
- const channelKey = getCurrentChannelKey() ?? "default";
116
- const projectRoot = config.squadEnabled ? (getChannelProject(channelKey) ?? undefined) : undefined;
117
- const squadDescriptor = (!agent && projectRoot)
118
- ? (() => { try {
119
- return findSquadAgent(projectRoot, args.agent_name);
120
- }
121
- catch {
122
- return null;
123
- } })()
124
- : null;
125
- if (!agent && !squadDescriptor) {
100
+ if (!agent) {
126
101
  const available = getAgentRegistry().map((a) => a.slug).join(", ");
127
102
  return `Agent '${args.agent_name}' not found. Available agents: ${available}`;
128
103
  }
129
- const delegatedSlug = agent?.slug ?? squadDescriptor?.slug ?? args.agent_name;
104
+ const delegatedSlug = agent.slug;
130
105
  let session;
131
- let isSquadAgent = false;
132
106
  try {
133
107
  const allTools = createTools(deps);
134
- if (squadDescriptor && projectRoot) {
135
- const squadContext = {
136
- projectRoot,
137
- squadDir: join(projectRoot, ".squad"),
138
- teamDir: join(projectRoot, ".squad"),
139
- personalDir: null,
140
- mode: "local",
141
- projectKey: null,
142
- config: {},
143
- agents: [squadDescriptor],
144
- decisionsPath: join(projectRoot, ".squad", "decisions.md"),
145
- loadedAt: new Date().toISOString(),
146
- };
147
- const charterPrefix = await buildSquadSystemPrefix(squadContext, squadDescriptor);
148
- session = await createSquadAgentSession(delegatedSlug, deps.client, allTools, charterPrefix, args.model_override || squadDescriptor.modelPreference);
149
- isSquadAgent = true;
150
- }
151
- else {
152
- session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override);
153
- }
108
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override);
154
109
  }
155
110
  catch (err) {
156
111
  const msg = err instanceof Error ? err.message : String(err);
@@ -212,28 +167,6 @@ export function createTools(deps) {
212
167
  completeTask(task.taskId, output);
213
168
  db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(output.slice(0, 10000), task.taskId);
214
169
  deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
215
- if (isSquadAgent && projectRoot && squadDescriptor) {
216
- try {
217
- const wikiDecisionPath = await mirrorDecisionToWiki({
218
- taskId: task.taskId,
219
- projectRoot,
220
- squadAgentSlug: squadDescriptor.slug,
221
- wikiDecisionPath: "",
222
- }, args.summary, output);
223
- db.prepare(`INSERT OR REPLACE INTO squad_task_links (task_id, project_root, squad_agent_slug, wiki_decision_path)
224
- VALUES (?, ?, ?, ?)`).run(task.taskId, projectRoot, squadDescriptor.slug, wikiDecisionPath);
225
- // Sync the full decisions.md file to the wiki so the page reflects
226
- // all 89+ entries, not just this task-completion entry.
227
- syncDecisionsFileToWiki(projectRoot).then(syncResult => {
228
- if (syncResult) {
229
- log.info({ entriesSynced: syncResult.entriesSynced, wikiPath: syncResult.wikiPath }, "Post-task squad decisions synced to wiki");
230
- }
231
- }).catch(() => { });
232
- }
233
- catch (mirrorErr) {
234
- log.error({ err: mirrorErr instanceof Error ? mirrorErr.message : mirrorErr }, "Failed to mirror squad decision to wiki (non-fatal)");
235
- }
236
- }
237
170
  }
238
171
  catch (err) {
239
172
  const msg = err instanceof Error ? err.message : String(err);
@@ -253,7 +186,7 @@ export function createTools(deps) {
253
186
  })();
254
187
  const model = (args.model_override && args.model_override.length > 0)
255
188
  ? args.model_override
256
- : squadDescriptor?.modelPreference || (agent?.model === "auto" ? "claude-sonnet-4.6" : agent?.model || "claude-sonnet-4.6");
189
+ : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
257
190
  return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
258
191
  },
259
192
  }),
@@ -323,12 +256,10 @@ export function createTools(deps) {
323
256
  },
324
257
  }),
325
258
  defineTool("show_agent_roster", {
326
- description: "List all registered agents with their name, model, status, and current tasks. When a squad project is active, also shows squad team members available via @mention.",
259
+ description: "List all registered agents with their name, model, status, and current tasks.",
327
260
  parameters: z.object({}),
328
261
  handler: async () => {
329
262
  const agents = getAgentRegistry();
330
- const channelKey = getCurrentChannelKey() ?? "default";
331
- const projectRoot = config.squadEnabled ? (getChannelProject(channelKey) ?? undefined) : undefined;
332
263
  const chLines = agents.map((a) => {
333
264
  const status = getAgentSessionStatus(a.slug);
334
265
  const runningTasks = status.tasks.filter((t) => t.status === "running");
@@ -338,25 +269,9 @@ export function createTools(deps) {
338
269
  : "";
339
270
  return `• @${a.slug} (${a.name}) — ${a.model} — ${badge}${taskInfo}\n ${a.description}`;
340
271
  });
341
- let squadSection = "";
342
- if (projectRoot) {
343
- try {
344
- const { renderProjectAgentRoster } = await import("../squad/registry.js");
345
- const squadRoster = renderProjectAgentRoster(projectRoot);
346
- if (squadRoster) {
347
- squadSection = `\n\nSquad agents (project: ${projectRoot}):\n${squadRoster}`;
348
- }
349
- }
350
- catch {
351
- // squad unavailable — skip
352
- }
353
- }
354
- else if (config.squadEnabled) {
355
- squadSection = "\n\n(Squad is enabled — select a project context to see squad agents)";
356
- }
357
- if (chLines.length === 0 && !squadSection)
272
+ if (chLines.length === 0)
358
273
  return "No agents registered.";
359
- return `Registered agents (${chLines.length}):\n${chLines.join("\n")}${squadSection}`;
274
+ return `Registered agents (${chLines.length}):\n${chLines.join("\n")}`;
360
275
  },
361
276
  }),
362
277
  defineTool("teams_notify", {
@@ -584,7 +499,6 @@ export function createTools(deps) {
584
499
  const session = await deps.client.resumeSession(args.session_id, {
585
500
  model: config.copilotModel,
586
501
  onPermissionRequest: approveAll,
587
- hooks: createSessionHooks(args.name),
588
502
  });
589
503
  const db = getDb();
590
504
  db.prepare(`INSERT OR REPLACE INTO agent_sessions (slug, copilot_session_id, model, status)
@@ -726,55 +640,73 @@ export function createTools(deps) {
726
640
  }),
727
641
  // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
728
642
  defineTool("remember", {
729
- description: "Save a fact, preference, or detail to the wiki. Routes to entity-specific pages automatically. " +
643
+ description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
730
644
  "Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
645
+ "Entity categories (project/person/org/tool/topic/area) are filed under pages/<category>/<topic>/index.md; " +
646
+ "pass `entity` for those, and `facet` to file under a sub-page like pages/projects/chapterhouse/feature-ideas.md. " +
647
+ "A `decision` is always recorded against an entity: pass both `about` (which entity category) and `entity`; " +
648
+ "it lands at pages/<about>/<entity>/decisions.md. " +
731
649
  "For richer knowledge pages, use wiki_update instead.",
732
650
  parameters: z.object({
733
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision"])
734
- .describe("Category: preference (likes/dislikes/settings), fact (general knowledge), project (codebase/repo info), person (people info), routine (schedules/habits), decision (choices made)"),
651
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"])
652
+ .describe("Category. Entity categories (need `entity`): project (codebase/repo), person (people), org (companies/teams), tool (software/technologies), topic (knowledge areas), area (areas of responsibility). decision (a choice made — needs `about` + `entity`). Flat categories: preference (likes/dislikes/settings), fact (general knowledge), routine (schedules/habits)."),
735
653
  content: z.string().describe("The thing to remember — a concise, self-contained statement"),
736
- entity: z.string().optional().describe("The specific entity this is about (e.g. 'team', 'chapterhouse', 'vercel'). Routes to a dedicated entity page."),
654
+ entity: z.string().optional().describe("Required for entity categories and for decisions: the specific topic this is about (e.g. 'chapterhouse', 'vercel', 'Brian'). Routes to pages/<category>/<topic-slug>/…"),
655
+ about: z.enum(["project", "person", "org", "tool", "topic", "area"]).optional()
656
+ .describe("Required only when category='decision': which entity category the decision concerns. The decision is filed at pages/<about>/<entity>/decisions.md."),
657
+ facet: z.string().optional().describe("Optional sub-page within the topic directory (e.g. 'feature-ideas', 'architecture'). Only meaningful with an entity category + `entity`. Defaults to the topic's index page. (For decisions use `about` instead.)"),
737
658
  related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
738
659
  }),
739
660
  handler: async (args) => {
740
661
  return withWikiWrite(async () => {
741
662
  ensureWikiStructure();
742
663
  const now = new Date().toISOString().slice(0, 10);
743
- // Entity routing: code-authoritative slugification and page lookup
664
+ const titleCase = (s) => s.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
665
+ // Routing: code-authoritative slugification and page lookup.
744
666
  let pagePath;
745
667
  let title;
746
- if (args.entity) {
747
- const slug = args.entity.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
748
- const categoryDir = getCategoryDir(args.category);
749
- pagePath = `pages/${categoryDir}/${slug}.md`;
750
- // Check for existing page with fuzzy match before creating new
751
- const existingPages = searchIndex(args.entity, 5);
752
- const existingMatch = existingPages.find((p) => {
753
- const pSlug = p.path.split("/").pop()?.replace(".md", "") || "";
754
- return pSlug === slug || p.title.toLowerCase() === args.entity.toLowerCase();
755
- });
756
- if (existingMatch) {
757
- pagePath = existingMatch.path;
758
- title = existingMatch.title;
668
+ let routedCategoryDir; // the directory under pages/ this ends up in
669
+ if (args.category === "decision") {
670
+ if (!args.about || !args.entity) {
671
+ return `A decision needs both 'about' (the entity category it concerns — one of: ${entityCategories().join(", ")}) and 'entity' (the specific one, e.g. "chapterhouse"). It will be filed at pages/<about>/<entity>/decisions.md.`;
759
672
  }
760
- else {
761
- title = args.entity.split(/[-_\s]+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
673
+ routedCategoryDir = getCategoryDir(args.about);
674
+ if (!entityCategories().includes(routedCategoryDir)) {
675
+ return `'${args.about}' isn't a known entity category. Use one of: ${entityCategories().join(", ")}.`;
762
676
  }
677
+ pagePath = topicPagePath(args.about, args.entity, "decisions");
678
+ const existingMatch = searchIndex(args.entity, 5).find((p) => p.path === pagePath);
679
+ title = existingMatch ? existingMatch.title : `${titleCase(args.entity)} — Decisions`;
763
680
  }
764
681
  else {
765
- const categoryMap = {
766
- preference: "pages/preferences.md",
767
- fact: "pages/facts.md",
768
- project: "pages/projects.md",
769
- person: "pages/people.md",
770
- routine: "pages/routines.md",
771
- decision: "pages/decisions.md",
772
- };
773
- pagePath = categoryMap[args.category] || `pages/${args.category}.md`;
774
- title = args.category.charAt(0).toUpperCase() + args.category.slice(1);
682
+ routedCategoryDir = getCategoryDir(args.category);
683
+ const isFlat = FLAT_CATEGORIES.includes(routedCategoryDir);
684
+ if (!isFlat) {
685
+ if (!args.entity) {
686
+ return `The '${args.category}' category needs an 'entity' (the specific ${args.category} this is about) so it can be filed under pages/${routedCategoryDir}/<topic>/. Re-call remember with an entity, e.g. entity: "chapterhouse".`;
687
+ }
688
+ const facet = args.facet ? slugify(args.facet) : "index";
689
+ pagePath = topicPagePath(args.category, args.entity, facet);
690
+ // Reuse an existing page if the index already has one at (or matching) this path.
691
+ const existingPages = searchIndex(args.entity, 5);
692
+ const existingMatch = existingPages.find((p) => p.path === pagePath ||
693
+ (facet === "index" && p.title.toLowerCase() === args.entity.toLowerCase() && p.path.startsWith(`pages/${routedCategoryDir}/`)));
694
+ if (existingMatch) {
695
+ pagePath = existingMatch.path;
696
+ title = existingMatch.title;
697
+ }
698
+ else {
699
+ const topicTitle = titleCase(args.entity);
700
+ title = facet === "index" ? topicTitle : `${topicTitle} — ${titleCase(facet)}`;
701
+ }
702
+ }
703
+ else {
704
+ pagePath = `pages/${routedCategoryDir}.md`;
705
+ title = titleCase(routedCategoryDir);
706
+ }
775
707
  }
776
708
  // Defense-in-depth: pagePath is constructed from controlled parts but
777
- // assertPagePath will catch any drift (e.g. an entity slug producing "..").
709
+ // assertPagePath also enforces the topic-structure rules.
778
710
  assertPagePath(pagePath);
779
711
  const existing = readPage(pagePath);
780
712
  if (existing) {
@@ -829,7 +761,7 @@ export function createTools(deps) {
829
761
  "Use when you need to look up something the user told you, or when asked 'do you remember...?'",
830
762
  parameters: z.object({
831
763
  keyword: z.string().optional().describe("Search term to match against wiki pages"),
832
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision"]).optional()
764
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"]).optional()
833
765
  .describe("Optional: filter by category"),
834
766
  }),
835
767
  handler: async (args) => {
@@ -992,9 +924,12 @@ export function createTools(deps) {
992
924
  }),
993
925
  defineTool("wiki_read", {
994
926
  description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " +
995
- "Paths are relative to the wiki root (e.g. 'pages/preferences.md', 'index.md').",
927
+ "Paths are relative to the wiki root. Layout: entity categories (projects, people, orgs, tools, " +
928
+ "topics, areas) live at 'pages/<category>/<topic>/index.md' (the topic overview) plus optional " +
929
+ "'pages/<category>/<topic>/<facet>.md' sub-pages; flat categories at 'pages/<category>.md' " +
930
+ "(preferences, facts, routines, decisions); daily summaries at 'pages/conversations/YYYY-MM-DD.md'.",
996
931
  parameters: z.object({
997
- path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian.md', 'index.md')"),
932
+ path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian/index.md', 'pages/projects/chapterhouse/decisions.md', 'index.md')"),
998
933
  }),
999
934
  handler: async (args) => {
1000
935
  ensureWikiStructure();
@@ -1008,9 +943,14 @@ export function createTools(deps) {
1008
943
  description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
1009
944
  "YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
1010
945
  "rich knowledge pages, entity pages, synthesis documents — anything more structured than " +
1011
- "a quick 'remember' call. After creating/updating a page, the index is automatically updated.",
946
+ "a quick 'remember' call. After creating/updating a page, the index is automatically updated. " +
947
+ "PATH RULES: entity-category pages MUST be 'pages/<category>/<topic-slug>/<page>.md' where " +
948
+ "category is one of projects, people, orgs, tools, topics, areas, '<page>' is 'index' for the " +
949
+ "topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
950
+ "lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
951
+ "routines, decisions). Bad paths are rejected with a suggested correction.",
1012
952
  parameters: z.object({
1013
- path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/max.md')"),
953
+ path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
1014
954
  title: z.string().describe("Page title for the index"),
1015
955
  summary: z.string().describe("One-line summary for the index"),
1016
956
  section: z.string().optional().describe("Index section (default: 'Knowledge')"),