chapterhouse 0.3.1 → 0.3.3

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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * src/squad/init.test.ts
3
+ *
4
+ * Tests for the Squad init scaffolding module.
5
+ *
6
+ * Coverage:
7
+ * - scaffoldSquad produces the expected .squad/ layout
8
+ * - isSquadInitialized correctly detects initialized/uninitialized dirs
9
+ * - Idempotence: scaffoldSquad returns null (does not clobber) on re-run
10
+ * - Dune universe character allocation
11
+ * - allocateCast uses the selected universe
12
+ * - resolveRoles clamps to valid presets
13
+ * - force flag overrides idempotence guard
14
+ */
15
+ import assert from 'node:assert/strict';
16
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { tmpdir as osTmpdir } from 'node:os';
19
+ import test from 'node:test';
20
+ import { scaffoldSquad, isSquadInitialized, allocateCast, resolveRoles, DUNE_CHARACTERS, UNIVERSE_CHARACTERS, DEFAULT_UNIVERSE, } from './init.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+ function makeTempDir() {
25
+ return mkdtempSync(join(osTmpdir(), 'ch-init-test-'));
26
+ }
27
+ function cleanup(dir) {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // Unit tests — pure functions
32
+ // ---------------------------------------------------------------------------
33
+ test('DUNE_CHARACTERS has at least 5 entries', () => {
34
+ assert.ok(DUNE_CHARACTERS.length >= 5, `Expected ≥ 5 Dune characters, got ${DUNE_CHARACTERS.length}`);
35
+ });
36
+ test('UNIVERSE_CHARACTERS includes Dune', () => {
37
+ assert.ok('Dune' in UNIVERSE_CHARACTERS, 'UNIVERSE_CHARACTERS must include "Dune"');
38
+ assert.equal(UNIVERSE_CHARACTERS['Dune'], DUNE_CHARACTERS);
39
+ });
40
+ test('DEFAULT_UNIVERSE is Dune', () => {
41
+ assert.equal(DEFAULT_UNIVERSE, 'Dune');
42
+ });
43
+ test('resolveRoles(4) returns 4 roles', () => {
44
+ const roles = resolveRoles(4);
45
+ assert.equal(roles.length, 4);
46
+ });
47
+ test('resolveRoles(3) returns 3 roles', () => {
48
+ assert.equal(resolveRoles(3).length, 3);
49
+ });
50
+ test('resolveRoles(5) returns 5 roles', () => {
51
+ assert.equal(resolveRoles(5).length, 5);
52
+ });
53
+ test('resolveRoles clamps values below 3 to 3', () => {
54
+ assert.equal(resolveRoles(1).length, 3);
55
+ });
56
+ test('resolveRoles clamps values above 5 to 5', () => {
57
+ assert.equal(resolveRoles(10).length, 5);
58
+ });
59
+ test('allocateCast assigns Dune names in order', () => {
60
+ const roles = resolveRoles(4);
61
+ const cast = allocateCast(roles, 'Dune');
62
+ assert.equal(Object.keys(cast).length, 4);
63
+ // First role maps to first Dune character
64
+ const firstRoleSlug = roles[0].slug;
65
+ assert.equal(cast[firstRoleSlug], DUNE_CHARACTERS[0]);
66
+ });
67
+ test('allocateCast uses provided universe', () => {
68
+ const roles = resolveRoles(3);
69
+ const cast = allocateCast(roles, 'Firefly');
70
+ const firfly = UNIVERSE_CHARACTERS['Firefly'];
71
+ const firstRoleSlug = roles[0].slug;
72
+ assert.equal(cast[firstRoleSlug], firfly[0]);
73
+ });
74
+ test('allocateCast falls back to Dune for unknown universe', () => {
75
+ const roles = resolveRoles(3);
76
+ const cast = allocateCast(roles, 'UnknownUniverse');
77
+ const firstRoleSlug = roles[0].slug;
78
+ assert.equal(cast[firstRoleSlug], DUNE_CHARACTERS[0]);
79
+ });
80
+ // ---------------------------------------------------------------------------
81
+ // isSquadInitialized
82
+ // ---------------------------------------------------------------------------
83
+ test('isSquadInitialized returns false for empty dir', () => {
84
+ const dir = makeTempDir();
85
+ try {
86
+ assert.equal(isSquadInitialized(dir), false);
87
+ }
88
+ finally {
89
+ cleanup(dir);
90
+ }
91
+ });
92
+ test('isSquadInitialized returns false when team.md missing', () => {
93
+ const dir = makeTempDir();
94
+ try {
95
+ assert.equal(isSquadInitialized(dir), false);
96
+ }
97
+ finally {
98
+ cleanup(dir);
99
+ }
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // scaffoldSquad integration
103
+ // ---------------------------------------------------------------------------
104
+ const BASE_CONFIG = {
105
+ projectName: 'TestApp',
106
+ stack: 'TypeScript, Node.js',
107
+ goal: 'A test application',
108
+ teamSize: 4,
109
+ universe: 'Dune',
110
+ humanName: 'Alice',
111
+ };
112
+ test('scaffoldSquad returns ScaffoldResult with 4 cast agents', () => {
113
+ const dir = makeTempDir();
114
+ try {
115
+ const result = scaffoldSquad(dir, BASE_CONFIG);
116
+ assert.ok(result !== null, 'scaffoldSquad should return a result');
117
+ assert.equal(result.agents.length, 4, 'Should cast 4 agents');
118
+ assert.equal(result.universe, 'Dune');
119
+ }
120
+ finally {
121
+ cleanup(dir);
122
+ }
123
+ });
124
+ test('scaffoldSquad creates .squad directory', () => {
125
+ const dir = makeTempDir();
126
+ try {
127
+ scaffoldSquad(dir, BASE_CONFIG);
128
+ assert.ok(existsSync(join(dir, '.squad')), '.squad/ must exist');
129
+ }
130
+ finally {
131
+ cleanup(dir);
132
+ }
133
+ });
134
+ test('scaffoldSquad creates team.md with ## Members section', () => {
135
+ const dir = makeTempDir();
136
+ try {
137
+ scaffoldSquad(dir, BASE_CONFIG);
138
+ const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
139
+ assert.ok(teamMd.includes('## Members'), 'team.md must contain ## Members');
140
+ assert.ok(teamMd.includes('Scribe'), 'team.md must list Scribe');
141
+ assert.ok(teamMd.includes('Ralph'), 'team.md must list Ralph');
142
+ }
143
+ finally {
144
+ cleanup(dir);
145
+ }
146
+ });
147
+ test('scaffoldSquad creates team.md with project info', () => {
148
+ const dir = makeTempDir();
149
+ try {
150
+ scaffoldSquad(dir, BASE_CONFIG);
151
+ const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
152
+ assert.ok(teamMd.includes('TestApp'), 'team.md must contain project name');
153
+ assert.ok(teamMd.includes('Dune'), 'team.md must contain universe');
154
+ assert.ok(teamMd.includes('Alice'), 'team.md must contain human name');
155
+ }
156
+ finally {
157
+ cleanup(dir);
158
+ }
159
+ });
160
+ test('scaffoldSquad creates routing.md', () => {
161
+ const dir = makeTempDir();
162
+ try {
163
+ scaffoldSquad(dir, BASE_CONFIG);
164
+ assert.ok(existsSync(join(dir, '.squad', 'routing.md')));
165
+ }
166
+ finally {
167
+ cleanup(dir);
168
+ }
169
+ });
170
+ test('scaffoldSquad creates ceremonies.md', () => {
171
+ const dir = makeTempDir();
172
+ try {
173
+ scaffoldSquad(dir, BASE_CONFIG);
174
+ assert.ok(existsSync(join(dir, '.squad', 'ceremonies.md')));
175
+ }
176
+ finally {
177
+ cleanup(dir);
178
+ }
179
+ });
180
+ test('scaffoldSquad creates decisions.md', () => {
181
+ const dir = makeTempDir();
182
+ try {
183
+ scaffoldSquad(dir, BASE_CONFIG);
184
+ assert.ok(existsSync(join(dir, '.squad', 'decisions.md')));
185
+ }
186
+ finally {
187
+ cleanup(dir);
188
+ }
189
+ });
190
+ test('scaffoldSquad creates agent charter.md for each cast agent', () => {
191
+ const dir = makeTempDir();
192
+ try {
193
+ const result = scaffoldSquad(dir, BASE_CONFIG);
194
+ for (const agent of result.agents) {
195
+ const charterPath = join(dir, '.squad', 'agents', agent.slug, 'charter.md');
196
+ assert.ok(existsSync(charterPath), `charter.md missing for ${agent.slug}`);
197
+ const content = readFileSync(charterPath, 'utf-8');
198
+ assert.ok(content.includes(agent.castName), `charter should mention ${agent.castName}`);
199
+ }
200
+ }
201
+ finally {
202
+ cleanup(dir);
203
+ }
204
+ });
205
+ test('scaffoldSquad creates agent history.md for each cast agent', () => {
206
+ const dir = makeTempDir();
207
+ try {
208
+ const result = scaffoldSquad(dir, BASE_CONFIG);
209
+ for (const agent of result.agents) {
210
+ const histPath = join(dir, '.squad', 'agents', agent.slug, 'history.md');
211
+ assert.ok(existsSync(histPath), `history.md missing for ${agent.slug}`);
212
+ }
213
+ }
214
+ finally {
215
+ cleanup(dir);
216
+ }
217
+ });
218
+ test('scaffoldSquad creates scribe and ralph agent dirs', () => {
219
+ const dir = makeTempDir();
220
+ try {
221
+ scaffoldSquad(dir, BASE_CONFIG);
222
+ assert.ok(existsSync(join(dir, '.squad', 'agents', 'scribe', 'charter.md')));
223
+ assert.ok(existsSync(join(dir, '.squad', 'agents', 'ralph', 'charter.md')));
224
+ }
225
+ finally {
226
+ cleanup(dir);
227
+ }
228
+ });
229
+ test('scaffoldSquad creates casting/policy.json', () => {
230
+ const dir = makeTempDir();
231
+ try {
232
+ scaffoldSquad(dir, BASE_CONFIG);
233
+ const policyPath = join(dir, '.squad', 'casting', 'policy.json');
234
+ assert.ok(existsSync(policyPath));
235
+ const policy = JSON.parse(readFileSync(policyPath, 'utf-8'));
236
+ assert.ok(Array.isArray(policy.allowlist_universes));
237
+ assert.ok(policy.allowlist_universes.includes('Dune'));
238
+ }
239
+ finally {
240
+ cleanup(dir);
241
+ }
242
+ });
243
+ test('scaffoldSquad creates casting/registry.json with all agents', () => {
244
+ const dir = makeTempDir();
245
+ try {
246
+ scaffoldSquad(dir, BASE_CONFIG);
247
+ const reg = JSON.parse(readFileSync(join(dir, '.squad', 'casting', 'registry.json'), 'utf-8'));
248
+ assert.ok(reg.agents, 'registry must have agents key');
249
+ assert.ok('scribe' in reg.agents, 'scribe must be in registry');
250
+ assert.ok('ralph' in reg.agents, 'ralph must be in registry');
251
+ }
252
+ finally {
253
+ cleanup(dir);
254
+ }
255
+ });
256
+ test('scaffoldSquad creates casting/history.json with assignment snapshot', () => {
257
+ const dir = makeTempDir();
258
+ try {
259
+ const result = scaffoldSquad(dir, BASE_CONFIG);
260
+ const hist = JSON.parse(readFileSync(join(dir, '.squad', 'casting', 'history.json'), 'utf-8'));
261
+ assert.ok(Array.isArray(hist.universe_usage_history));
262
+ assert.equal(hist.universe_usage_history[0].universe, 'Dune');
263
+ assert.ok(result.assignmentId in hist.assignment_cast_snapshots);
264
+ }
265
+ finally {
266
+ cleanup(dir);
267
+ }
268
+ });
269
+ test('scaffoldSquad creates or updates .gitattributes', () => {
270
+ const dir = makeTempDir();
271
+ try {
272
+ scaffoldSquad(dir, BASE_CONFIG);
273
+ const ga = readFileSync(join(dir, '.gitattributes'), 'utf-8');
274
+ assert.ok(ga.includes('.squad/decisions.md merge=union'), '.gitattributes must include union merge for decisions.md');
275
+ assert.ok(ga.includes('.squad/agents/*/history.md merge=union'));
276
+ }
277
+ finally {
278
+ cleanup(dir);
279
+ }
280
+ });
281
+ test('scaffoldSquad is idempotent — returns null on second call', () => {
282
+ const dir = makeTempDir();
283
+ try {
284
+ const first = scaffoldSquad(dir, BASE_CONFIG);
285
+ assert.ok(first !== null, 'first call should succeed');
286
+ const second = scaffoldSquad(dir, BASE_CONFIG);
287
+ assert.equal(second, null, 'second call should return null (idempotent)');
288
+ }
289
+ finally {
290
+ cleanup(dir);
291
+ }
292
+ });
293
+ test('scaffoldSquad with force:true overwrites existing scaffold', () => {
294
+ const dir = makeTempDir();
295
+ try {
296
+ scaffoldSquad(dir, BASE_CONFIG);
297
+ const result = scaffoldSquad(dir, { ...BASE_CONFIG, projectName: 'Updated' }, { force: true });
298
+ assert.ok(result !== null, 'forced call should succeed');
299
+ const teamMd = readFileSync(join(dir, '.squad', 'team.md'), 'utf-8');
300
+ assert.ok(teamMd.includes('Updated'), 'team.md should reflect updated project name');
301
+ }
302
+ finally {
303
+ cleanup(dir);
304
+ }
305
+ });
306
+ test('isSquadInitialized returns true after scaffoldSquad', () => {
307
+ const dir = makeTempDir();
308
+ try {
309
+ scaffoldSquad(dir, BASE_CONFIG);
310
+ assert.equal(isSquadInitialized(dir), true);
311
+ }
312
+ finally {
313
+ cleanup(dir);
314
+ }
315
+ });
316
+ test('scaffoldSquad uses Dune names from the canonical pool', () => {
317
+ const dir = makeTempDir();
318
+ try {
319
+ const result = scaffoldSquad(dir, BASE_CONFIG);
320
+ for (const agent of result.agents) {
321
+ assert.ok(DUNE_CHARACTERS.includes(agent.castName), `${agent.castName} is not in the Dune character pool`);
322
+ }
323
+ }
324
+ finally {
325
+ cleanup(dir);
326
+ }
327
+ });
328
+ test('scaffoldSquad uses Firefly names when universe is Firefly', () => {
329
+ const dir = makeTempDir();
330
+ try {
331
+ const result = scaffoldSquad(dir, { ...BASE_CONFIG, universe: 'Firefly' });
332
+ const fireflyPool = UNIVERSE_CHARACTERS['Firefly'];
333
+ for (const agent of result.agents) {
334
+ assert.ok(fireflyPool.includes(agent.castName), `${agent.castName} is not in the Firefly pool`);
335
+ }
336
+ }
337
+ finally {
338
+ cleanup(dir);
339
+ }
340
+ });
341
+ test('scaffoldSquad creates decisions/inbox directory', () => {
342
+ const dir = makeTempDir();
343
+ try {
344
+ scaffoldSquad(dir, BASE_CONFIG);
345
+ assert.ok(existsSync(join(dir, '.squad', 'decisions', 'inbox')));
346
+ }
347
+ finally {
348
+ cleanup(dir);
349
+ }
350
+ });
351
+ //# sourceMappingURL=init.test.js.map
@@ -2,6 +2,8 @@ import { basename, join } from 'path';
2
2
  import { existsSync, readFileSync, readdirSync } from 'fs';
3
3
  import { readPage, writePage } from '../wiki/fs.js';
4
4
  import { ensureWikiStructure } from '../wiki/fs.js';
5
+ import { childLogger } from '../util/logger.js';
6
+ const log = childLogger('squad.mirror');
5
7
  /**
6
8
  * Returns the wiki-relative path for a project's squad decisions page.
7
9
  * e.g. "pages/projects/chapterhouse/decisions.md"
@@ -69,12 +71,12 @@ export async function syncDecisionsFileToWiki(projectRoot) {
69
71
  // Visibility check: warn if inbox has unmerged drops
70
72
  const inbox = await checkDecisionsInbox(projectRoot);
71
73
  if (inbox.count > 0) {
72
- console.warn(`[squad] Project at ${projectRoot} has ${inbox.count} unmerged decision drops in inbox: ${inbox.files.join(', ')}. Run Scribe to merge.`);
74
+ log.warn({ projectRoot, count: inbox.count, files: inbox.files }, 'unmerged decision drops in inbox run Scribe to merge');
73
75
  }
74
76
  return { entriesSynced, wikiPath };
75
77
  }
76
78
  catch (err) {
77
- console.error('[squad] syncDecisionsFileToWiki failed (non-fatal):', err instanceof Error ? err.message : err);
79
+ log.error({ err: err instanceof Error ? err.message : err }, 'syncDecisionsFileToWiki failed (non-fatal)');
78
80
  return null;
79
81
  }
80
82
  }
@@ -1,5 +1,7 @@
1
1
  import { getDb } from '../store/db.js';
2
2
  import { syncDecisionsFileToWiki } from './mirror.js';
3
+ import { childLogger } from '../util/logger.js';
4
+ const log = childLogger('squad.mirror-scheduler');
3
5
  export const DEFAULT_DECISIONS_SYNC_INTERVAL_MS = 300_000; // 5 minutes
4
6
  function readIntervalFromEnv() {
5
7
  const raw = process.env.CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS;
@@ -17,7 +19,7 @@ function defaultGetRegisteredProjectRoots() {
17
19
  return rows.map(r => r.project_root);
18
20
  }
19
21
  catch (err) {
20
- console.error('[squad] DecisionsSyncScheduler: failed to query registered projects (non-fatal):', err instanceof Error ? err.message : err);
22
+ log.error({ err: err instanceof Error ? err.message : err }, 'failed to query registered projects (non-fatal)');
21
23
  return [];
22
24
  }
23
25
  }
@@ -38,12 +40,12 @@ export class DecisionsSyncScheduler {
38
40
  /** Start the periodic sync. No-op if intervalMs <= 0. */
39
41
  start() {
40
42
  if (this.intervalMs <= 0) {
41
- console.log('[squad] DecisionsSyncScheduler: disabled (intervalMs <= 0)');
43
+ log.debug({ intervalMs: this.intervalMs }, 'disabled (intervalMs <= 0)');
42
44
  return;
43
45
  }
44
46
  if (this.handle !== undefined)
45
47
  return; // already running
46
- console.log(`[squad] DecisionsSyncScheduler: starting, interval=${this.intervalMs}ms`);
48
+ log.info({ intervalMs: this.intervalMs }, 'starting');
47
49
  this.handle = this.setIntervalImpl(() => { void this.tick(); }, this.intervalMs);
48
50
  // Allow Node.js to exit even if the interval is still active
49
51
  this.handle?.unref?.();
@@ -53,24 +55,24 @@ export class DecisionsSyncScheduler {
53
55
  if (this.handle !== undefined) {
54
56
  this.clearIntervalImpl(this.handle);
55
57
  this.handle = undefined;
56
- console.log('[squad] DecisionsSyncScheduler: stopped');
58
+ log.info('stopped');
57
59
  }
58
60
  }
59
61
  async tick() {
60
62
  const projects = this.getRegisteredProjectRoots();
61
63
  if (projects.length === 0)
62
64
  return;
63
- console.log(`[squad] DecisionsSyncScheduler: syncing ${projects.length} project(s)`);
65
+ log.debug({ projects: projects.length }, 'syncing');
64
66
  for (const root of projects) {
65
67
  try {
66
68
  const result = await this.syncFn(root);
67
69
  if (result) {
68
- console.log(`[squad] DecisionsSyncScheduler: synced ${result.entriesSynced} entries for ${root}`);
70
+ log.debug({ entriesSynced: result.entriesSynced, root }, 'synced');
69
71
  }
70
72
  }
71
73
  catch (err) {
72
74
  // syncDecisionsFileToWiki already swallows errors; belt-and-suspenders here
73
- console.error(`[squad] DecisionsSyncScheduler: unexpected error for ${root}:`, err instanceof Error ? err.message : err);
75
+ log.error({ root, err: err instanceof Error ? err.message : err }, 'unexpected sync error (non-fatal)');
74
76
  }
75
77
  }
76
78
  }
package/dist/store/db.js CHANGED
@@ -150,6 +150,24 @@ export function getDb() {
150
150
  if (!taskCols.some((c) => c.name === 'source')) {
151
151
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
152
152
  }
153
+ // agent_task_events: append-only per-task tool-call activity log for /workers streaming
154
+ db.exec(`
155
+ CREATE TABLE IF NOT EXISTS agent_task_events (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
158
+ seq INTEGER NOT NULL,
159
+ ts INTEGER NOT NULL,
160
+ kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
161
+ tool_name TEXT,
162
+ summary TEXT
163
+ )
164
+ `);
165
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
166
+ // Migrate: add event_seq column to agent_tasks for monotonic event numbering
167
+ const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
168
+ if (!taskColsNow.some((c) => c.name === 'event_seq')) {
169
+ db.exec(`ALTER TABLE agent_tasks ADD COLUMN event_seq INTEGER NOT NULL DEFAULT 0`);
170
+ }
153
171
  // Migrate: add last_used_at column to project_squads (epoch ms, nullable)
154
172
  const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
155
173
  if (!projectCols.some((c) => c.name === 'last_used_at')) {
@@ -313,11 +331,46 @@ export function getSessionMessages(sessionKey, limit) {
313
331
  ts: r.ts,
314
332
  }));
315
333
  }
316
- // ---------------------------------------------------------------------------
317
- // SQLite memory functions removed wiki is the single source of truth.
318
- // The memories table and FTS5 index are preserved in the schema for safety
319
- // (existing data is not deleted), but no code reads or writes to them.
320
- // ---------------------------------------------------------------------------
334
+ /**
335
+ * Append one event to agent_task_events and return the new event.
336
+ * Uses a transaction so seq is monotonically incremented.
337
+ * Non-fatal: silently ignores DB errors (task may not exist yet due to race).
338
+ */
339
+ export function appendTaskEvent(taskId, kind, toolName, summary) {
340
+ const db = getDb();
341
+ try {
342
+ return db.transaction(() => {
343
+ db.prepare(`UPDATE agent_tasks SET event_seq = event_seq + 1 WHERE task_id = ?`).run(taskId);
344
+ const row = db.prepare(`SELECT event_seq FROM agent_tasks WHERE task_id = ?`).get(taskId);
345
+ if (!row)
346
+ return undefined;
347
+ const seq = row.event_seq;
348
+ const ts = Date.now();
349
+ const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
350
+ return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
351
+ })();
352
+ }
353
+ catch {
354
+ return undefined;
355
+ }
356
+ }
357
+ /**
358
+ * Return all events for a task ordered by seq ascending.
359
+ */
360
+ export function getTaskEvents(taskId, afterSeq = 0) {
361
+ const db = getDb();
362
+ const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
363
+ FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
364
+ return rows.map((r) => ({
365
+ id: r.id,
366
+ taskId: r.task_id,
367
+ seq: r.seq,
368
+ ts: r.ts,
369
+ kind: r.kind,
370
+ toolName: r.tool_name,
371
+ summary: r.summary,
372
+ }));
373
+ }
321
374
  export function bumpProjectLastUsed(projectRoot) {
322
375
  getDb()
323
376
  .prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
@@ -211,4 +211,73 @@ test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async
211
211
  dbModule.closeDb();
212
212
  }
213
213
  });
214
+ // ---------------------------------------------------------------------------
215
+ // #86: agent_task_events — appendTaskEvent and getTaskEvents
216
+ // ---------------------------------------------------------------------------
217
+ test("#86: appendTaskEvent inserts a row and getTaskEvents returns it ordered by seq", async () => {
218
+ const dbModule = await loadDbModule();
219
+ try {
220
+ const db = dbModule.getDb();
221
+ // Insert a parent task row
222
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-001', 'kaylee', 'Fix streaming', 'running')`).run();
223
+ const ev1 = dbModule.appendTaskEvent("task-sse-001", "tool_start", "bash", "npm run build");
224
+ assert.ok(ev1, "appendTaskEvent must return the inserted event");
225
+ assert.equal(ev1.kind, "tool_start");
226
+ assert.equal(ev1.toolName, "bash");
227
+ assert.equal(ev1.summary, "npm run build");
228
+ assert.equal(ev1.seq, 1);
229
+ const ev2 = dbModule.appendTaskEvent("task-sse-001", "tool_complete", null, "ok");
230
+ assert.ok(ev2, "second appendTaskEvent must return a second event");
231
+ assert.equal(ev2.seq, 2, "seq must be monotonically incremented");
232
+ assert.equal(ev2.kind, "tool_complete");
233
+ const events = dbModule.getTaskEvents("task-sse-001");
234
+ assert.equal(events.length, 2, "getTaskEvents must return 2 events");
235
+ assert.equal(events[0].seq, 1);
236
+ assert.equal(events[1].seq, 2);
237
+ }
238
+ finally {
239
+ dbModule.closeDb();
240
+ }
241
+ });
242
+ test("#86: getTaskEvents with afterSeq filters earlier events", async () => {
243
+ const dbModule = await loadDbModule();
244
+ try {
245
+ const db = dbModule.getDb();
246
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-002', 'wash', 'UI fix', 'running')`).run();
247
+ dbModule.appendTaskEvent("task-sse-002", "tool_start", "view", "/some/file");
248
+ dbModule.appendTaskEvent("task-sse-002", "tool_complete", null, "ok");
249
+ dbModule.appendTaskEvent("task-sse-002", "tool_start", "bash", "git push");
250
+ const all = dbModule.getTaskEvents("task-sse-002");
251
+ assert.equal(all.length, 3, "all 3 events expected");
252
+ const afterFirst = dbModule.getTaskEvents("task-sse-002", 1);
253
+ assert.equal(afterFirst.length, 2, "afterSeq=1 must return only events with seq > 1");
254
+ assert.equal(afterFirst[0].seq, 2);
255
+ }
256
+ finally {
257
+ dbModule.closeDb();
258
+ }
259
+ });
260
+ test("#86: appendTaskEvent returns undefined (non-fatal) for unknown task_id", async () => {
261
+ const dbModule = await loadDbModule();
262
+ try {
263
+ dbModule.getDb();
264
+ const result = dbModule.appendTaskEvent("no-such-task", "tool_start", "bash", "echo hi");
265
+ assert.equal(result, undefined, "appendTaskEvent must return undefined for unknown task_id");
266
+ }
267
+ finally {
268
+ dbModule.closeDb();
269
+ }
270
+ });
271
+ test("#86: agent_task_events table exists in schema after getDb()", async () => {
272
+ const dbModule = await loadDbModule();
273
+ try {
274
+ const db = dbModule.getDb();
275
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
276
+ const tableNames = new Set(tables.map((r) => r.name));
277
+ assert.ok(tableNames.has("agent_task_events"), "agent_task_events table must exist");
278
+ }
279
+ finally {
280
+ dbModule.closeDb();
281
+ }
282
+ });
214
283
  //# sourceMappingURL=db.test.js.map
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
6
+ export const CHAPTERHOUSE_VERSION = pkg.version;
7
+ //# sourceMappingURL=version.js.map
@@ -4,6 +4,8 @@ import { config } from "../config.js";
4
4
  import { WIKI_DIR } from "../paths.js";
5
5
  import { assertPagePath, readPage, writePage, writeFileAtomic } from "./fs.js";
6
6
  import { addToIndex, buildIndexEntryForPage } from "./index-manager.js";
7
+ import { childLogger } from "../util/logger.js";
8
+ const log = childLogger("team-sync");
7
9
  export class TeamWikiSync {
8
10
  teamChapterhouseUrl;
9
11
  teamChapterhouseToken;
@@ -28,7 +30,7 @@ export class TeamWikiSync {
28
30
  this.cacheRoot = join(this.wikiDir, ".team-cache");
29
31
  this.manifestPath = join(this.cacheRoot, "manifest.json");
30
32
  this.fetchImpl = options.fetchImpl ?? fetch;
31
- this.warn = options.warn ?? ((message) => console.warn(message));
33
+ this.warn = options.warn ?? ((message) => log.warn(message));
32
34
  this.now = options.now ?? (() => new Date());
33
35
  }
34
36
  isEnabled() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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"
@@ -22,12 +22,12 @@
22
22
  "dev:server": "tsx --watch src/daemon.ts",
23
23
  "dev:web": "npm --prefix web run dev",
24
24
  "dev": "tsx --watch src/daemon.ts",
25
+ "lint:md": "markdownlint-cli2 'README.md' 'CHANGELOG.md' 'docs/**/*.md' '.github/**/*.md'",
25
26
  "release:check": "if [ -n \"$(git status --porcelain)\" ]; then echo '❌ Working tree is not clean. Stage or stash changes before running npm version.'; git status --short; exit 1; fi",
26
27
  "preversion": "npm run release:check",
27
28
  "prepare": "husky",
28
29
  "test": "npm run clean && npm run build:server && node --experimental-test-module-mocks --import ./dist/test/setup-env.js --test 'dist/**/*.test.js'",
29
- "prepublishOnly": "npm run build",
30
- "prepare": "husky"
30
+ "prepublishOnly": "npm run build"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=22.5.0"
@@ -74,6 +74,7 @@
74
74
  "@types/jsonwebtoken": "^9.0.10",
75
75
  "@types/node": "^25.6.0",
76
76
  "husky": "^9.1.7",
77
+ "markdownlint-cli2": "^0.22.1",
77
78
  "tsx": "^4.21.0",
78
79
  "typescript": "^5.9.3"
79
80
  }