chapterhouse 0.4.1 → 0.4.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.
@@ -34,6 +34,7 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
34
34
  "agent_tasks",
35
35
  "projects",
36
36
  "max_state",
37
+ "daemon_runs",
37
38
  "conversation_log",
38
39
  "memories",
39
40
  ]) {
@@ -57,6 +58,53 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
57
58
  dbModule.closeDb();
58
59
  }
59
60
  });
61
+ test("getDb initializes action-item memory schema and FTS shadow", async () => {
62
+ const dbModule = await loadDbModule();
63
+ try {
64
+ const db = dbModule.getDb();
65
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
66
+ const tableNames = new Set(tables.map((row) => row.name));
67
+ assert.equal(tableNames.has("mem_action_items"), true, "expected mem_action_items table");
68
+ assert.equal(tableNames.has("mem_action_items_fts"), true, "expected mem_action_items_fts virtual table");
69
+ const columns = db.prepare(`PRAGMA table_info(mem_action_items)`).all();
70
+ const columnNames = new Set(columns.map((column) => column.name));
71
+ for (const name of [
72
+ "id",
73
+ "scope_id",
74
+ "entity_id",
75
+ "title",
76
+ "detail",
77
+ "status",
78
+ "due_at",
79
+ "snooze_until",
80
+ "source",
81
+ "created_at",
82
+ "updated_at",
83
+ "resolved_at",
84
+ "resolution_reason",
85
+ "tier",
86
+ "tier_pinned_at",
87
+ "tier_reason",
88
+ "last_recalled_at",
89
+ ]) {
90
+ assert.equal(columnNames.has(name), true, `expected mem_action_items.${name}`);
91
+ }
92
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
93
+ const inserted = db.prepare(`
94
+ INSERT INTO mem_action_items (scope_id, title, detail, source)
95
+ VALUES (?, 'Action FTS sentinel', 'Searchable migration reminder', 'test')
96
+ `).run(scope.id);
97
+ const ftsHits = db.prepare(`
98
+ SELECT rowid
99
+ FROM mem_action_items_fts
100
+ WHERE mem_action_items_fts MATCH 'migration'
101
+ `).all();
102
+ assert.equal(ftsHits.some((hit) => hit.rowid === Number(inserted.lastInsertRowid)), true);
103
+ }
104
+ finally {
105
+ dbModule.closeDb();
106
+ }
107
+ });
60
108
  test("getDb migrates legacy conversation_log tables to allow system messages", async () => {
61
109
  const seedDb = new Database(dbPath);
62
110
  seedDb.exec(`
@@ -181,15 +229,23 @@ test("getDb migrates legacy memory tiers from glacier to cold and preserves expl
181
229
  const dbModule = await loadDbModule();
182
230
  try {
183
231
  const db = dbModule.getDb();
184
- assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_entities ORDER BY id`).all(), [
232
+ assert.deepEqual(db.prepare(`
233
+ SELECT id, tier FROM mem_entities
234
+ WHERE name IN ('warm kept', 'cold mapped')
235
+ ORDER BY id
236
+ `).all(), [
185
237
  { id: 1, tier: "warm" },
186
238
  { id: 2, tier: "cold" },
187
239
  ]);
188
- assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_observations ORDER BY id`).all(), [
240
+ assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_observations WHERE source = 'test' ORDER BY id`).all(), [
189
241
  { id: 1, tier: "hot" },
190
242
  { id: 2, tier: "cold" },
191
243
  ]);
192
- assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_decisions ORDER BY id`).all(), [
244
+ assert.deepEqual(db.prepare(`
245
+ SELECT id, tier FROM mem_decisions
246
+ WHERE title IN ('warm kept', 'cold mapped')
247
+ ORDER BY id
248
+ `).all(), [
193
249
  { id: 1, tier: "warm" },
194
250
  { id: 2, tier: "cold" },
195
251
  ]);
@@ -200,6 +256,305 @@ test("getDb migrates legacy memory tiers from glacier to cold and preserves expl
200
256
  dbModule.closeDb();
201
257
  }
202
258
  });
259
+ test("getDb seeds the Chapterhouse entity and PR1 wiki observations idempotently", async () => {
260
+ const dbModule = await loadDbModule();
261
+ try {
262
+ const expectedObservations = [
263
+ {
264
+ content: "Chapterhouse architecture: Node.js daemon, web UI, per-conversation sessions, specialist agents, modular skills, MCP servers, local markdown wiki, project-rules prompt injection, and background workers with SSE event streaming.",
265
+ tier: "warm",
266
+ },
267
+ {
268
+ content: "Chapterhouse source lives at bketelsen/chapterhouse with local checkout ~/projects/chapterhouse; engineering plans are in docs/plans/ and product specs are in docs/prd/.",
269
+ tier: "warm",
270
+ },
271
+ {
272
+ content: "Background agent completions arrive as automatic system notifications; after spawning a background agent, end the turn and wait instead of polling read_agent, sleeping, or re-checking list_agents.",
273
+ tier: "hot",
274
+ },
275
+ {
276
+ content: "Use the default Chapterhouse chat for research and general questions; move code changes against a specific project into that project's chat session.",
277
+ tier: "warm",
278
+ },
279
+ {
280
+ content: "Squad manages Chapterhouse's own development workflow.",
281
+ tier: "warm",
282
+ },
283
+ {
284
+ content: "Chapterhouse uses frequent semver patch releases published to npm via the Publish to npm GitHub Actions workflow on tag push; never publish manually.",
285
+ tier: "hot",
286
+ },
287
+ {
288
+ content: "Agent-memory P1 shipped on 2026-05-13 as Chapterhouse v0.4.0 across 7 PRs: #215 through #225.",
289
+ tier: "warm",
290
+ },
291
+ {
292
+ content: "The v0.3.2 per-session orchestrator was confirmed under real concurrent load on 2026-05-08, enabling concurrent sessions without cross-blocking.",
293
+ tier: "hot",
294
+ },
295
+ ];
296
+ let db = dbModule.getDb();
297
+ dbModule.closeDb();
298
+ db = dbModule.getDb();
299
+ const chapterhouseScope = db.prepare(`
300
+ SELECT id, slug
301
+ FROM mem_scopes
302
+ WHERE slug = 'chapterhouse'
303
+ `).get();
304
+ assert.ok(chapterhouseScope, "chapterhouse scope should be seeded");
305
+ const entity = db.prepare(`
306
+ SELECT id, scope_id, kind, name, summary, tier
307
+ FROM mem_entities
308
+ WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
309
+ `).get(chapterhouseScope.id);
310
+ assert.ok(entity, "Chapterhouse project entity should be seeded");
311
+ assert.equal(entity.scope_id, chapterhouseScope.id);
312
+ assert.equal(entity.summary, "Always-on team-level AI assistant daemon that orchestrates specialist subagents and maintains a persistent knowledge wiki.");
313
+ const rows = db.prepare(`
314
+ SELECT scope_id, entity_id, content, source, tier, tier_pinned_at, tier_reason
315
+ FROM mem_observations
316
+ WHERE source = 'wiki:pages/projects/chapterhouse/index.md'
317
+ ORDER BY content
318
+ `).all();
319
+ assert.equal(rows.length, expectedObservations.length, "PR1 should seed exactly 8 index.md observations");
320
+ for (const expected of expectedObservations) {
321
+ const row = rows.find((entry) => entry.content === expected.content);
322
+ assert.ok(row, `missing seeded observation: ${expected.content}`);
323
+ assert.equal(row.scope_id, chapterhouseScope.id, "all PR1 observations must be scoped to chapterhouse");
324
+ assert.equal(row.entity_id, entity.id, "all PR1 observations should attach to the Chapterhouse entity");
325
+ assert.equal(row.tier, expected.tier);
326
+ if (expected.tier === "hot") {
327
+ assert.ok(row.tier_pinned_at, "hot PR1 observations should be manually pinned");
328
+ assert.equal(row.tier_reason, "P6 PR1 wiki migration hot-tier candidate");
329
+ }
330
+ }
331
+ const duplicates = db.prepare(`
332
+ SELECT content, COUNT(*) AS count
333
+ FROM mem_observations
334
+ WHERE source = 'wiki:pages/projects/chapterhouse/index.md'
335
+ GROUP BY content
336
+ HAVING COUNT(*) > 1
337
+ `).all();
338
+ assert.deepEqual(duplicates, [], "re-running getDb must not duplicate seeded observations");
339
+ const entityCount = db.prepare(`
340
+ SELECT COUNT(*) AS count
341
+ FROM mem_entities
342
+ WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
343
+ `).get(chapterhouseScope.id).count;
344
+ assert.equal(entityCount, 1, "re-running getDb must not duplicate the Chapterhouse entity");
345
+ }
346
+ finally {
347
+ dbModule.closeDb();
348
+ }
349
+ });
350
+ test("getDb seeds the Chapterhouse PR2 wiki decisions idempotently", async () => {
351
+ const dbModule = await loadDbModule();
352
+ try {
353
+ const expectedDecisions = [
354
+ {
355
+ title: "Standardize API validation and JSON errors around `src/api/errors.ts`",
356
+ rationale: "Keep one 400/403/404/500 contract across routes instead of ad hoc parsing and responses; `src/api/errors.ts`, `src/api/server.ts`.",
357
+ decided_at: "2026-05-06",
358
+ tier: "warm",
359
+ },
360
+ {
361
+ title: "Run in standalone mode when neither Entra nor an API token is configured",
362
+ rationale: "Disable auth checks and team sync together so local single-user startup still works; `src/config.ts`, `src/api/auth.ts`.",
363
+ decided_at: "2026-05-06",
364
+ tier: "warm",
365
+ },
366
+ {
367
+ title: "Use bearer auth for SSE and serialize wiki writes",
368
+ rationale: "`/stream` no longer accepts query tokens, and wiki mutations go through `withWikiWrite()`; `web/src/stream.ts`, `src/wiki/lock.ts`.",
369
+ decided_at: "2026-05-06",
370
+ tier: "warm",
371
+ },
372
+ {
373
+ title: "Default production to least privilege",
374
+ rationale: "Run as a non-root container, validate env early, and keep CORS and security headers tight in production; `Dockerfile`, `deploy/deploy.sh`, `src/config.ts`.",
375
+ decided_at: "2026-05-06",
376
+ tier: "warm",
377
+ },
378
+ {
379
+ title: "Keep chat state session-scoped by `sessionKey`",
380
+ rationale: "Conversation history, SSE routing, and frontend buffers stay isolated per session instead of bleeding across chats; `src/store/db.ts`, `web/src/store.ts`.",
381
+ decided_at: "2026-05-07",
382
+ tier: "warm",
383
+ },
384
+ {
385
+ title: "Preserve the 3-layer daemon timing contract",
386
+ rationale: "Orchestrator timeout must stay below daemon shutdown grace, which must stay below systemd `TimeoutStopSec`; `README.md`, `src/daemon.ts`, `src/daemon-install.ts`.",
387
+ decided_at: "2026-05-08",
388
+ tier: "hot",
389
+ },
390
+ {
391
+ title: "Start WorkIQ MCP auto-install at daemon startup",
392
+ rationale: "Write the MCP entry before the SDK client starts so new sessions see it immediately; issue #33, PR #78.",
393
+ decided_at: "2026-05-08",
394
+ tier: "warm",
395
+ },
396
+ {
397
+ title: "Use per-session orchestrators instead of a global queue",
398
+ rationale: "`SessionManager` and `SessionRegistry` keep independent queues so one chat cannot block another; issue #74, `src/copilot/session-manager.ts`.",
399
+ decided_at: "2026-05-08",
400
+ tier: "hot",
401
+ },
402
+ {
403
+ title: "Make wiki path hierarchy the primary navigation model",
404
+ rationale: "`path` drives the tree and breadcrumbs, while `section` remains secondary metadata; `web/src/wiki/index.ts`, `web/src/routes/Wiki.tsx`.",
405
+ decided_at: "2026-05-06",
406
+ tier: "warm",
407
+ },
408
+ {
409
+ title: "Route wiki breadcrumbs through `selected` state",
410
+ rationale: "Breadcrumb clicks use the same route-state flow as the sidebar so navigation stays predictable; `web/src/components/wiki/WikiBreadcrumbs.tsx`.",
411
+ decided_at: "2026-05-06",
412
+ tier: "warm",
413
+ },
414
+ {
415
+ title: "Derive wiki page scope from configured team paths",
416
+ rationale: "The API returns `scope` and the UI shows personal/team indicators without adding new persisted metadata; `src/api/server.ts`, `web/src/components/wiki/WikiScopeIndicator.tsx`.",
417
+ decided_at: "2026-05-06",
418
+ tier: "warm",
419
+ },
420
+ {
421
+ title: "Sort sidebar recents by actual project activity",
422
+ rationale: "`last_used_at` replaces registration time so recent projects reflect chat usage rather than signup order; issue #26, PR #30.",
423
+ decided_at: "2026-05-08",
424
+ tier: "warm",
425
+ },
426
+ {
427
+ title: "Preload `CHAPTERHOUSE_DISABLE_DOTENV=1` for Node tests",
428
+ rationale: "Tests must not inherit developer-local env files before config singletons initialize; `src/test/setup-env.ts`, `src/config.ts`.",
429
+ decided_at: "2026-05-06",
430
+ tier: "warm",
431
+ },
432
+ {
433
+ title: "Test API routes through a spawned server process",
434
+ rationale: "Real HTTP coverage catches auth, config preload, error middleware, and wiki I/O behavior that an unexported in-process app would miss; `src/api/server.test.ts`.",
435
+ decided_at: "2026-05-06",
436
+ tier: "warm",
437
+ },
438
+ {
439
+ title: "Centralize frontend API Zod schemas in `web/src/api-schemas.ts`",
440
+ rationale: "Keep one audit surface for the browser-to-daemon contract and require validated JSON reads; issue #40, PR #63.",
441
+ decided_at: "2026-05-08",
442
+ tier: "warm",
443
+ },
444
+ {
445
+ title: "Make frontend test isolation an infrastructure concern",
446
+ rationale: "Vitest globals, `test-setup.ts`, and MSW own cleanup so tests do not hand-roll it; issue #43, PR #62.",
447
+ decided_at: "2026-05-08",
448
+ tier: "warm",
449
+ },
450
+ {
451
+ title: "Clean `dist/` before `npm test`",
452
+ rationale: "Remove stale compiled artifacts so deleted tests cannot linger in the Node test run; `package.json`.",
453
+ decided_at: "2026-05-08",
454
+ tier: "warm",
455
+ },
456
+ {
457
+ title: "Adopt `pino` as the backend logger",
458
+ rationale: "Use `childLogger()` everywhere and keep chat content at `debug`, not `info`; issue #13, PR #28.",
459
+ decided_at: "2026-05-08",
460
+ tier: "warm",
461
+ },
462
+ {
463
+ title: "Keep daemon control as a thin CLI over `src/daemon-install.ts`",
464
+ rationale: "Generate service artifacts in one module and support launchd and systemd user services without a separate control layer; issue #14, PR #24.",
465
+ decided_at: "2026-05-08",
466
+ tier: "warm",
467
+ },
468
+ {
469
+ title: "Default publish workflows to Node 24+",
470
+ rationale: "npm Trusted Publishing depends on npm 11.5.1+, which Node 22 does not provide; `.github/workflows/npm-publish.yml`.",
471
+ decided_at: "2026-05-08",
472
+ tier: "warm",
473
+ },
474
+ {
475
+ title: "Make `chapterhouse update` registry-aware",
476
+ rationale: "Registry installs update via npm, while legacy git installs keep the older pull-and-rebuild path; issue #31, PR #32.",
477
+ decided_at: "2026-05-08",
478
+ tier: "warm",
479
+ },
480
+ {
481
+ title: "Keep markdownlint pragmatic rather than maximalist",
482
+ rationale: "Disable MD060, ignore `.github/agents/**`, and suppress MD041 only where template UX requires it; `.markdownlint-cli2.jsonc`.",
483
+ decided_at: "2026-05-08",
484
+ tier: "warm",
485
+ },
486
+ {
487
+ title: "Enforce issue-closing references in feature and fix PRs",
488
+ rationale: "PR bodies must include `Closes #N`, `Fixes #N`, or `Resolves #N`, with a documented `no-issue` bypass; PR #60, `.github/workflows/lint-pr-closes.yml`.",
489
+ decided_at: "2026-05-08",
490
+ tier: "warm",
491
+ },
492
+ {
493
+ title: "Use Conventional Commits for commits and PR titles",
494
+ rationale: "Keep repo history machine-readable and enforce it with commitlint, husky, and PR-title linting; `commitlint.config.js`, `.github/workflows/lint-pr-title.yml`.",
495
+ decided_at: "2026-05-08",
496
+ tier: "warm",
497
+ },
498
+ ];
499
+ let db = dbModule.getDb();
500
+ dbModule.closeDb();
501
+ db = dbModule.getDb();
502
+ const chapterhouseScope = db.prepare(`
503
+ SELECT id
504
+ FROM mem_scopes
505
+ WHERE slug = 'chapterhouse'
506
+ `).get();
507
+ assert.ok(chapterhouseScope, "chapterhouse scope should be seeded");
508
+ const chapterhouseEntity = db.prepare(`
509
+ SELECT id
510
+ FROM mem_entities
511
+ WHERE scope_id = ? AND kind = 'project' AND name = 'Chapterhouse'
512
+ `).get(chapterhouseScope.id);
513
+ assert.ok(chapterhouseEntity, "Chapterhouse project entity should be seeded");
514
+ const rows = db.prepare(`
515
+ SELECT title, rationale, decided_at, tier, entity_id, tier_pinned_at, tier_reason, superseded_by
516
+ FROM mem_decisions
517
+ WHERE scope_id = ?
518
+ ORDER BY decided_at, title
519
+ `).all(chapterhouseScope.id);
520
+ assert.equal(rows.length, 24, "PR2 should seed exactly 24 decisions from decisions.md");
521
+ assert.equal(expectedDecisions.length, 24, "test fixture should reflect the wiki source decision count");
522
+ for (const expected of expectedDecisions) {
523
+ const row = rows.find((entry) => entry.title === expected.title);
524
+ assert.ok(row, `missing seeded decision: ${expected.title}`);
525
+ assert.equal(row.rationale, expected.rationale);
526
+ assert.equal(row.decided_at, expected.decided_at);
527
+ assert.equal(row.tier, expected.tier);
528
+ assert.equal(row.entity_id, chapterhouseEntity.id, "all PR2 decisions should attach to the Chapterhouse entity");
529
+ assert.equal(row.superseded_by, null, "source has no explicit superseded-by markers");
530
+ if (expected.tier === "hot") {
531
+ assert.ok(row.tier_pinned_at, "hot PR2 decisions should be manually pinned");
532
+ assert.equal(row.tier_reason, "P6 PR2 wiki migration hot-tier candidate");
533
+ }
534
+ else {
535
+ assert.equal(row.tier_pinned_at, null);
536
+ assert.equal(row.tier_reason, null);
537
+ }
538
+ }
539
+ const duplicates = db.prepare(`
540
+ SELECT title, COUNT(*) AS count
541
+ FROM mem_decisions
542
+ WHERE scope_id = ?
543
+ GROUP BY title
544
+ HAVING COUNT(*) > 1
545
+ `).all(chapterhouseScope.id);
546
+ assert.deepEqual(duplicates, [], "re-running getDb must not duplicate seeded decisions");
547
+ const supersededCount = db.prepare(`
548
+ SELECT COUNT(*) AS count
549
+ FROM mem_decisions
550
+ WHERE scope_id = ? AND superseded_by IS NOT NULL
551
+ `).get(chapterhouseScope.id).count;
552
+ assert.equal(supersededCount, 0, "source has no explicit superseded-by markers to port");
553
+ }
554
+ finally {
555
+ dbModule.closeDb();
556
+ }
557
+ });
203
558
  test("getDb prunes oversized conversation logs on startup and during inserts", async () => {
204
559
  const seedDb = new Database(dbPath);
205
560
  seedDb.exec(`
@@ -251,12 +606,15 @@ test("getSessionMessages returns empty array for unknown session", async () => {
251
606
  test("getSessionMessages returns structured messages in chronological order, includes agent completions, excludes system rows, respects limit", async () => {
252
607
  const dbModule = await loadDbModule();
253
608
  try {
254
- const db = dbModule.getDb();
609
+ dbModule.getDb();
255
610
  dbModule.logConversation("user", "hello", "web", "test-session");
256
611
  dbModule.logConversation("assistant", "hi there", "web", "test-session");
257
612
  dbModule.logConversation("system", "system noise", "worker", "test-session");
258
- db.prepare(`INSERT INTO conversation_log (role, content, source, session_key)
259
- VALUES ('agent_completion', ?, 'background', 'test-session')`).run("[Agent task completed] @coder finished task task-1:\n\nDone");
613
+ dbModule.logConversation("agent_completion", "Done", "background", "test-session", {
614
+ agentSlug: "coder",
615
+ agentDisplayName: "Kaylee",
616
+ turnId: "agent-turn-1",
617
+ });
260
618
  dbModule.logConversation("user", "second message", "web", "test-session");
261
619
  dbModule.logConversation("user", "from other session", "web", "other-session");
262
620
  const all = dbModule.getSessionMessages("test-session");
@@ -266,14 +624,19 @@ test("getSessionMessages returns structured messages in chronological order, inc
266
624
  assert.equal(all[1].role, "assistant");
267
625
  assert.equal(all[1].content, "hi there");
268
626
  assert.equal(all[2].role, "assistant");
269
- assert.equal(all[2].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
627
+ assert.equal(all[2].content, "Done");
628
+ assert.equal(all[2].agentSlug, "coder");
629
+ assert.equal(all[2].agentDisplayName, "Kaylee");
630
+ assert.equal(all[2].turnId, "agent-turn-1");
270
631
  assert.equal(all[3].role, "user");
271
632
  assert.equal(all[3].content, "second message");
272
633
  // Limit clamping
273
634
  const limited = dbModule.getSessionMessages("test-session", 2);
274
635
  assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
275
636
  // After reversal, these should be the 2 most-recent renderable rows.
276
- assert.equal(limited[0].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
637
+ assert.equal(limited[0].content, "Done");
638
+ assert.equal(limited[0].agentSlug, "coder");
639
+ assert.equal(limited[0].turnId, "agent-turn-1");
277
640
  assert.equal(limited[1].content, "second message");
278
641
  // Other session not leaked
279
642
  const other = dbModule.getSessionMessages("other-session");
@@ -284,6 +647,85 @@ test("getSessionMessages returns structured messages in chronological order, inc
284
647
  dbModule.closeDb();
285
648
  }
286
649
  });
650
+ test("getSessionMessages returns stable row id and turn_id for hydration reconciliation", async () => {
651
+ const dbModule = await loadDbModule();
652
+ try {
653
+ const db = dbModule.getDb();
654
+ const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
655
+ assert.equal(columns.some((column) => column.name === "turn_id"), true, "conversation_log should persist turn_id");
656
+ const result = db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, turn_id, run_id)
657
+ VALUES ('assistant', 'hydrated answer', 'web', 'stable-session', 'turn-stable-1', ?)`).run(dbModule.getCurrentRunId());
658
+ const messages = dbModule.getSessionMessages("stable-session");
659
+ assert.equal(messages.length, 1);
660
+ const message = messages[0];
661
+ assert.equal(message.id, Number(result.lastInsertRowid));
662
+ assert.equal(message.turn_id, "turn-stable-1");
663
+ assert.equal(message.turnId, "turn-stable-1");
664
+ }
665
+ finally {
666
+ dbModule.closeDb();
667
+ }
668
+ });
669
+ test("getSessionMessages defaults to the current daemon run and can include historical rows", async () => {
670
+ const dbModule = await loadDbModule();
671
+ try {
672
+ const db = dbModule.getDb();
673
+ const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
674
+ if (!columns.some((column) => column.name === "run_id")) {
675
+ db.exec(`ALTER TABLE conversation_log ADD COLUMN run_id TEXT`);
676
+ }
677
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
678
+ VALUES ('user', ?, 'web', 'run-session', ?)`).run("previous run", "previous-run");
679
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
680
+ VALUES ('assistant', ?, 'web', 'run-session', ?)`).run("current run", "current-run");
681
+ const getSessionMessages = dbModule.getSessionMessages;
682
+ assert.deepEqual(getSessionMessages("run-session", undefined, { runId: "current-run" }).map((message) => message.content), ["current run"]);
683
+ assert.deepEqual(getSessionMessages("run-session", undefined, { runId: "current-run", includeHistorical: true }).map((message) => message.content), ["previous run", "current run"]);
684
+ }
685
+ finally {
686
+ dbModule.closeDb();
687
+ }
688
+ });
689
+ test("getDb migrates legacy hydration rows with NULL run_id and excludes them by default", async () => {
690
+ const seedDb = new Database(dbPath);
691
+ seedDb.exec(`
692
+ CREATE TABLE conversation_log (
693
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
694
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
695
+ content TEXT NOT NULL,
696
+ source TEXT NOT NULL DEFAULT 'unknown',
697
+ session_key TEXT NOT NULL DEFAULT 'default',
698
+ ts DATETIME DEFAULT CURRENT_TIMESTAMP
699
+ );
700
+ INSERT INTO conversation_log (role, content, source, session_key)
701
+ VALUES ('user', 'legacy row', 'web', 'legacy-session');
702
+ CREATE TABLE turn_events (
703
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
704
+ turn_id TEXT NOT NULL,
705
+ session_key TEXT NOT NULL DEFAULT 'default',
706
+ seq INTEGER NOT NULL,
707
+ ts INTEGER NOT NULL,
708
+ event_type TEXT NOT NULL,
709
+ payload TEXT NOT NULL
710
+ );
711
+ INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
712
+ VALUES ('legacy-turn', 'legacy-session', 1, 1, 'turn:complete', '{"type":"turn:complete","turnId":"legacy-turn","sessionKey":"legacy-session","finalMessage":"legacy","_seq":1,"_ts":1}');
713
+ `);
714
+ seedDb.close();
715
+ const dbModule = await loadDbModule();
716
+ try {
717
+ const db = dbModule.getDb();
718
+ const columns = db.prepare(`PRAGMA table_info(conversation_log)`).all();
719
+ assert.ok(columns.some((column) => column.name === "run_id"), "conversation_log.run_id column must be added");
720
+ const turnColumns = db.prepare(`PRAGMA table_info(turn_events)`).all();
721
+ assert.ok(turnColumns.some((column) => column.name === "run_id"), "turn_events.run_id column must be added");
722
+ dbModule.logConversation("assistant", "current row", "web", "legacy-session");
723
+ assert.deepEqual(dbModule.getSessionMessages("legacy-session").map((message) => message.content), ["current row"]);
724
+ }
725
+ finally {
726
+ dbModule.closeDb();
727
+ }
728
+ });
287
729
  // ---------------------------------------------------------------------------
288
730
  // #86: agent_task_events — appendTaskEvent and getTaskEvents
289
731
  // ---------------------------------------------------------------------------
@@ -441,8 +883,8 @@ test("getSessionMessages returns ts values normalized to ISO-8601 UTC (ends with
441
883
  try {
442
884
  const db = dbModule.getDb();
443
885
  // Insert a row with a bare SQLite-format timestamp (no T, no Z)
444
- db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, ts)
445
- VALUES ('user', 'timezone test', 'web', 'tz-session', '2026-05-09 01:23:45')`).run();
886
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id, ts)
887
+ VALUES ('user', 'timezone test', 'web', 'tz-session', ?, '2026-05-09 01:23:45')`).run(dbModule.getCurrentRunId());
446
888
  const messages = dbModule.getSessionMessages("tz-session");
447
889
  assert.equal(messages.length, 1, "should return the inserted row");
448
890
  assert.equal(messages[0].ts, "2026-05-09T01:23:45Z", "ts must be normalized to ISO-8601 UTC");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.4.1",
3
+ "version": "0.4.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"
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub Dark
3
+ Description: Dark theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-dark
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}:root{color-scheme:dark;--bg: #0e1116;--bg-elev: #161b22;--bg-elev-2: #21262d;--fg: #e6edf3;--fg-dim: #8b949e;--border: #30363d;--accent: #3b82f6;--accent-fg: #ffffff;--danger: #f87171;--user-bubble: #1e293b}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,sans-serif;font-size:14px;line-height:1.5}a,.link{color:var(--accent);text-decoration:none}a:hover,.link:hover{text-decoration:underline}button,input,textarea,select{font:inherit}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;background:var(--bg-elev-2);padding:1px 5px;border-radius:4px}.dim{color:var(--fg-dim)}.small{font-size:12px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:absolute;left:16px;top:-48px;z-index:10;padding:10px 14px;border-radius:8px;background:var(--accent);color:var(--accent-fg)}.skip-link:focus{top:16px}.layout{display:grid;grid-template-columns:220px 1fr;height:100%}.sidebar{background:var(--bg-elev);border-right:1px solid var(--border);padding:16px 0;display:flex;flex-direction:column}.sidebar-brand{display:flex;align-items:center;gap:10px;padding:0 18px 18px;font-weight:600;font-size:16px;border-bottom:1px solid var(--border);margin-bottom:12px}.sidebar nav{display:flex;flex-direction:column}.nav-link{padding:9px 18px;color:var(--fg);border-left:2px solid transparent}.nav-link:hover{background:var(--bg-elev-2);text-decoration:none}.nav-link.active{background:var(--bg-elev-2);border-left-color:var(--accent);color:var(--fg)}.nav-group{display:flex;flex-direction:column}.nav-group-row{display:flex;align-items:stretch}.nav-group-label{flex:1}.nav-group-toggle{background:none;border:none;cursor:pointer;padding:0 14px 0 4px;color:var(--fg-muted, var(--fg));display:flex;align-items:center;justify-content:center;border-left:2px solid transparent}.nav-group-toggle:hover{background:var(--bg-elev-2)}.nav-chevron{display:inline-block;font-size:18px;line-height:1;transition:transform .18s ease;transform:rotate(0)}.nav-chevron-open{transform:rotate(90deg)}.nav-recents{list-style:none;margin:0;padding:0}.nav-recent-link{display:flex;align-items:baseline;justify-content:space-between;gap:6px;width:100%;background:none;border:none;border-left:2px solid transparent;padding:6px 18px 6px 28px;cursor:pointer;color:var(--fg);text-align:left;font-size:13px}.nav-recent-link:hover{background:var(--bg-elev-2);text-decoration:none;border-left-color:var(--accent)}.nav-recent-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.nav-recent-hint{font-size:11px;flex-shrink:0;opacity:.6}.nav-recents-empty{padding:4px 28px 6px;font-size:12px;margin:0}.main{overflow:hidden;display:flex;flex-direction:column}.app-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 24px;border-bottom:1px solid var(--border);background:var(--bg-elev)}.app-header-title{font-size:16px;font-weight:600;margin:0}.app-header-title-row{display:inline-flex;align-items:center;gap:10px}.app-header-user{color:var(--fg-dim);font-size:13px}.mode-badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600;line-height:1;border:1px solid transparent}.mode-standalone{background:var(--bg-elev-2);border-color:var(--border);color:var(--fg-dim)}.mode-team{background:color-mix(in srgb,var(--accent) 14%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,var(--border));color:var(--accent)}.sse-badge{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600;line-height:1;border:1px solid transparent}.sse-badge__dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0}.sse-badge--reconnecting{background:color-mix(in srgb,#f59e0b 12%,transparent);border-color:color-mix(in srgb,#f59e0b 35%,var(--border));color:#b45309}.sse-badge--reconnecting .sse-badge__dot{background:#f59e0b;animation:sse-pulse 1.2s ease-in-out infinite}.sse-badge--disconnected{background:color-mix(in srgb,#ef4444 12%,transparent);border-color:color-mix(in srgb,#ef4444 35%,var(--border));color:#b91c1c}.sse-badge--disconnected .sse-badge__dot{background:#ef4444}.sse-badge__reconnect-btn{background:none;border:none;padding:0;margin-left:4px;font-size:12px;font-weight:600;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.sse-badge__reconnect-btn:hover{opacity:.8}@keyframes sse-pulse{0%,to{opacity:1}50%{opacity:.35}}max-width: 760px; margin: 0 auto; padding: 32px; } .loading,.empty-state{padding:32px;color:var(--fg-dim)}.empty-state h2{color:var(--fg);margin-top:0;margin-bottom:8px}.empty-state p{margin:0 0 12px}.empty-state-icon{font-size:28px;margin-bottom:8px;line-height:1}.empty-state-action{margin-top:4px}.auth-screen{min-height:100%;display:grid;place-items:center;padding:32px}.auth-card{width:min(420px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;padding:24px}.auth-card h1{margin-top:0;margin-bottom:8px}.auth-card p{margin-top:0;margin-bottom:20px;color:var(--fg-dim)}.page{padding:24px 32px;overflow:auto;flex:1;min-width:0}.page-header{margin-bottom:16px}.page-header h1{margin:0 0 4px;font-size:22px}.projects-table-wrap{overflow-x:auto}.projects-table{width:100%;border-collapse:collapse;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;overflow:hidden}.projects-table th,.projects-table td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}.projects-table thead th{background:var(--bg-elev-2);font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-dim)}.projects-table tbody tr:last-child th,.projects-table tbody tr:last-child td{border-bottom:0}.project-detail-backlink{display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;font-size:13px}.project-detail-summary{margin:0;color:var(--fg-dim)}.project-detail-layout{display:grid;gap:16px}.project-detail-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;padding:18px}.project-detail-card h2{margin:0 0 14px;font-size:16px}.project-detail-meta{display:grid;grid-template-columns:minmax(120px,180px) 1fr;gap:10px 16px;margin:0}.project-detail-meta dt{color:var(--fg-dim);font-weight:600}.project-detail-meta dd{margin:0}.project-detail-actions{display:flex;justify-content:flex-end;margin-top:16px}.project-hard-rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:0}.project-hard-rule{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px}.project-hard-rule dt{color:var(--fg-dim);font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.project-hard-rule dd{margin:6px 0 0;font-size:14px}.project-hard-rules-form{display:grid;gap:14px}.project-hard-rule-input{display:grid;gap:10px;align-content:start}.project-hard-rule-label{color:var(--fg-dim);font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.project-hard-rule input[type=checkbox]{width:16px;height:16px}.project-hard-rule-text-input{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;padding:6px 10px;width:100%}.project-hard-rule-text-input:focus{outline:none;border-color:var(--accent)}.project-soft-rules-form{display:grid;gap:12px}.project-soft-rules-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));align-items:start}.project-soft-rules-editor,.project-soft-rules-preview-card{min-width:0}.project-soft-rules-label{color:var(--fg-dim);font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.project-soft-rules-input{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;line-height:1.5;min-height:140px;padding:10px 12px;resize:vertical;width:100%}.project-soft-rules-input:focus{outline:none;border-color:var(--accent)}.project-soft-rules-hint{color:var(--fg-dim);font-size:12px;margin:0}.project-soft-rules-preview-card{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px}.project-soft-rules-preview-title{margin:0 0 10px;color:var(--fg-dim);font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.project-soft-rules-preview{min-height:140px}.project-soft-rules-preview .md>:first-child{margin-top:0}.project-soft-rules-preview .md>:last-child{margin-bottom:0}.project-save-feedback{margin:0;font-size:12px}.project-save-feedback-success{color:#4ade80}.project-save-feedback-error{color:var(--danger)}.project-detail-empty{padding:0}.projects-count{width:120px}.error-notice{background:#f871711a;border:1px solid var(--danger);color:var(--danger);padding:12px 14px;border-radius:8px;margin-bottom:16px}.error-notice.inline{margin-bottom:12px}.error-notice-title{margin:0 0 4px;font-size:16px}.error-notice-message{margin:0}.error-notice-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.error-details{background:var(--bg-elev-2);padding:12px;border-radius:8px;overflow:auto}.loading-state{display:flex;align-items:flex-start;gap:12px;padding:16px 0;color:var(--fg-dim)}.loading-state.inline{padding:10px 0}.loading-state.centered{justify-content:center;padding:48px 32px}.loading-spinner{width:18px;height:18px;border:2px solid rgba(59,130,246,.25);border-top-color:var(--accent);border-radius:999px;flex:none;margin-top:2px;animation:spin .9s linear infinite}.loading-state-label{color:var(--fg);font-weight:500}.loading-state-detail{margin-top:2px}.btn{background:var(--bg-elev-2);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer}.btn:hover{background:var(--bg-elev)}.btn.primary{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}.btn.primary:hover{filter:brightness(1.1)}.btn.danger{border-color:var(--danger);color:var(--danger)}.btn.cancel{background:var(--danger);border-color:var(--danger);color:var(--accent-fg)}.chat{display:flex;flex-direction:column;height:100%}.chat-scroll{flex:1;overflow:auto;padding:24px 32px 0}.chat-log{display:flex;flex-direction:column}.turn-wrapper{margin-bottom:18px}.turn-wrapper+.turn-wrapper:not(.has-separator) .bubble{border-top:1px solid var(--border);padding-top:14px}.turn-header{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}.turn-header--user{justify-content:flex-end}.turn-actor-badge{display:inline-flex;align-items:center;gap:4px;background:var(--bg-elev);border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:11px;font-weight:500;-webkit-user-select:none;user-select:none}.turn-header--agent .turn-actor-badge{background:color-mix(in srgb,var(--accent) 10%,var(--bg-elev));border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}.turn-actor-icon{font-size:11px;line-height:1}.turn-agent-slug{color:var(--accent);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;font-weight:600}.turn-ts{font-size:11px;color:var(--fg-dim);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;cursor:default;-webkit-user-select:none;user-select:none}.time-separator{display:flex;align-items:center;gap:10px;margin:16px 0 10px;color:var(--fg-dim);font-size:11px;font-style:italic;-webkit-user-select:none;user-select:none}.time-separator-line{flex:1;height:1px;background:var(--border);opacity:.5}.time-separator-label{white-space:nowrap;opacity:.7;letter-spacing:.02em}.bubble{max-width:800px}.bubble.user{margin-left:auto;text-align:right}.bubble--agent-activity{opacity:.88;border-left:2px solid var(--accent, #6366f1);padding-left:10px}.bubble.user .user-text{display:inline-block;background:var(--user-bubble);border:1px solid var(--border);padding:8px 14px;border-radius:14px;white-space:pre-wrap;text-align:left;margin:0}.route-tag{font-size:11px;color:var(--fg-dim);margin-top:4px}.copy-btn-wrap{position:relative}.copy-btn{position:absolute;top:6px;right:6px;display:flex;align-items:center;justify-content:center;padding:4px;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg-dim);cursor:pointer;z-index:1;line-height:0;transition:color .15s,background .15s}.copy-btn:hover{background:var(--bg-elev-2);color:var(--fg)}.copy-btn--copied{color:#4ade80;border-color:#4ade80}@media (hover: hover){.copy-btn{opacity:0;pointer-events:none;transition:opacity .15s,color .15s,background .15s}.copy-btn-wrap:hover .copy-btn,.copy-btn-wrap:focus-within .copy-btn{opacity:1;pointer-events:auto}}.copy-btn--code{top:8px;right:8px}.activity-heartbeat{display:flex;align-items:center;gap:6px;padding:4px 8px;margin:0 0 6px;border-radius:6px;border:1px solid rgba(59,130,246,.35);background:var(--bg-elev-2);font-size:12px;color:var(--fg);transition:opacity .3s}.activity-heartbeat.stale{opacity:.5;border-color:var(--border);color:var(--fg-dim)}.heartbeat-spinner{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);display:inline-block;animation:spin 1.4s linear infinite}.activity-heartbeat.stale .heartbeat-spinner{animation:none;color:var(--fg-dim)}.heartbeat-agent{font-weight:600}.heartbeat-sep{color:var(--fg-dim)}.heartbeat-action{color:var(--fg-dim);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.heartbeat-elapsed{font-family:ui-monospace,monospace;font-size:11px;color:var(--fg-dim);white-space:nowrap}.activity-strip{margin:0 0 8px;font-size:12px}.activity-summary{display:flex;flex-wrap:wrap;gap:6px}.activity-pill{display:inline-flex;align-items:center;gap:6px;background:var(--bg-elev);border:1px solid var(--border);color:var(--fg-dim);padding:3px 10px;border-radius:999px;cursor:pointer;font-size:12px}.activity-pill:hover{background:var(--bg-elev-2)}.activity-pill.running{color:var(--accent);border-color:#3b82f673}.activity-pill .glyph{font-family:ui-monospace,monospace;font-size:11px}.activity-pill.running .glyph{display:inline-block;animation:spin 1s linear infinite}.activity-pill .caret{color:var(--fg-dim);font-size:10px}.activity-headlines{display:flex;flex-direction:column;gap:2px;margin-top:6px}.activity-headline{display:inline-flex;align-items:center;gap:6px;padding:2px 4px;color:var(--fg-dim)}.activity-headline.status-running{color:var(--accent)}.activity-headline.status-failed{color:var(--danger)}.activity-headline .glyph{font-family:ui-monospace,monospace;font-size:11px;width:12px;text-align:center}.activity-headline.status-running .glyph{animation:spin 1s linear infinite}.agent-tag{font-size:10px;text-transform:lowercase;background:#3b82f629;color:#93c5fd;border:1px solid rgba(59,130,246,.35);padding:1px 6px;border-radius:4px;letter-spacing:.02em}.activity-thinking,.activity-details{margin-top:8px;padding:10px 12px;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px}.activity-details{display:flex;flex-direction:column;gap:6px}.thinking-block{margin:0;padding:8px;background:var(--bg-elev-2);border-radius:4px;white-space:pre-wrap;font-size:12px;line-height:1.5;max-height:280px;overflow:auto}.activity-row{border:1px solid var(--border);border-radius:6px;background:var(--bg-elev-2)}.activity-row.status-running{border-color:#3b82f673}.activity-row.status-failed{border-color:var(--danger)}.activity-row-head{width:100%;display:flex;align-items:center;gap:8px;background:transparent;border:0;color:var(--fg);text-align:left;padding:6px 10px;cursor:pointer;font-size:12px}.activity-row.status-running .activity-row-head .glyph{animation:spin 1s linear infinite;color:var(--accent)}.activity-row.status-failed .activity-row-head .glyph{color:var(--danger)}.activity-row .glyph{font-family:ui-monospace,monospace;width:12px;text-align:center}.activity-row .caret{margin-left:auto;color:var(--fg-dim)}.activity-row-body{padding:0 10px 10px;display:flex;flex-direction:column;gap:6px}.row-label{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--fg-dim)}.composer{border-top:1px solid var(--border);background:var(--bg-elev);padding:14px 32px;display:flex;flex-direction:column;gap:8px}.composer textarea{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:10px;resize:vertical}.composer-help{margin-top:-2px}.dreaming-indicator{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fg-dim);padding:4px 0;animation:pulse 2s ease-in-out infinite}.dreaming-indicator-glyph{color:#c4b5fd}.composer-actions{display:flex;justify-content:flex-end;gap:6px}.md{line-height:1.55}.md p:first-child{margin-top:0}.md p:last-child{margin-bottom:0}.md pre{background:var(--bg-elev-2);border-radius:6px;padding:12px;overflow:auto}.md pre code{background:transparent;padding:0}.md table{border-collapse:collapse;margin:1em 0}.md th,.md td{border:1px solid var(--border);padding:6px 10px}.workers-layout{display:grid;grid-template-columns:320px 1fr;gap:18px;align-items:start}.workers-list{display:flex;flex-direction:column;gap:6px}.worker-row{text-align:left;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;color:var(--fg)}.worker-row.selected,.worker-row:hover{background:var(--bg-elev-2)}.worker-row-head{display:flex;justify-content:space-between;align-items:center}.worker-status{font-size:11px;font-weight:600;padding:2px 7px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em}.worker-status--running{background:color-mix(in srgb,var(--accent) 15%,transparent);color:var(--accent)}.worker-status--completed{background:color-mix(in srgb,#4caf50 15%,transparent);color:#4caf50}.worker-status--error{background:color-mix(in srgb,#f44336 15%,transparent);color:#f44336}.worker-row-desc{margin-top:4px;font-size:13px;color:var(--fg)}.workers-detail{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:18px}.worker-detail-description{margin:4px 0 8px;font-size:15px}.worker-detail-slug,.worker-detail-taskid{font-size:.75em;font-family:var(--font-mono, monospace)}.worker-detail-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:8px}.worker-events{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto;background:var(--bg-elev-2);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:12px;font-family:var(--font-mono, monospace)}.worker-event{display:flex;gap:8px;align-items:baseline;line-height:1.5}.worker-event-ts{color:var(--text-dim, #888);flex-shrink:0;font-size:11px}.worker-event-body{display:flex;gap:4px;align-items:baseline;flex-wrap:wrap;overflow:hidden}.worker-event-icon{flex-shrink:0}.worker-event--tool_complete .worker-event-icon{opacity:.7}.msg-queued-indicator{font-size:.8em;opacity:.6;vertical-align:middle;-webkit-user-select:none;user-select:none}.msg-queued-backend-indicator{display:inline-block;font-size:.78em;opacity:.75;margin-top:4px;color:var(--fg-dim);-webkit-user-select:none;user-select:none}.output{background:var(--bg-elev-2);padding:12px;border-radius:6px;overflow:auto;white-space:pre-wrap;font-size:13px}.projects-toolbar{margin-bottom:16px}.projects-register-form{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.projects-path-input{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;padding:6px 10px;width:380px;max-width:100%}.projects-path-input:focus{outline:none;border-color:var(--accent)}.projects-register-error{font-size:12px}.projects-disabled{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:18px}.projects-empty{padding:24px 0}.projects-list{display:flex;flex-direction:column;gap:8px}.project-row{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.project-row-info{display:flex;flex-direction:column;gap:4px;min-width:0}.project-root{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.project-meta{display:flex;gap:12px;font-size:12px;flex-wrap:wrap}.project-badge{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:10px;padding:1px 8px;font-size:11px;color:var(--fg)}.project-row-actions{display:flex;gap:6px;flex-shrink:0}.project-chat-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 16px;background:color-mix(in srgb,var(--accent) 8%,var(--bg-elev));border-bottom:1px solid color-mix(in srgb,var(--accent) 20%,var(--border));flex-shrink:0}.project-chat-header-identity{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}.project-chat-icon{font-size:16px;flex-shrink:0}.project-chat-title{font-size:14px;white-space:nowrap;flex-shrink:0}.project-chat-path{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.wiki{display:flex;flex-direction:column;min-height:100%}.wiki-layout{display:grid;grid-template-columns:minmax(320px,360px) minmax(0,1fr);gap:20px;flex:1;min-height:0}.wiki-sidebar,.wiki-main{min-height:0}.wiki-sidebar{display:flex;flex-direction:column;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;overflow:hidden}.wiki-sidebar-header{position:sticky;top:0;z-index:1;display:flex;flex-direction:column;gap:14px;padding:16px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-elev) 0%,rgba(22,27,34,.98) 100%)}.wiki-sidebar-header-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.wiki-sidebar-header-row h2{margin:0 0 4px;font-size:16px}.wiki-sidebar-header-row p{margin:0}.wiki-search{display:flex;flex-direction:column;gap:12px}.wiki-search-field input,.wiki-filter select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);padding:9px 10px;border-radius:8px}.wiki-filter{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--fg-dim)}.wiki-search-meta,.wiki-shortcuts,.wiki-scope-legend{color:var(--fg-dim)}.wiki-scope-header-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.wiki-scope-header-row h1{margin:0}.wiki-shortcuts{border-top:1px solid var(--border);padding-top:12px}.wiki-scope-legend{display:flex;flex-wrap:wrap;gap:8px}.wiki-scope-legend>span{display:inline-flex;align-items:center;gap:4px}.wiki-sidebar-body{flex:1;min-height:0;overflow:auto;padding:12px}.wiki-tree,.wiki-tree-children{list-style:none;margin:0;padding:0}.wiki-tree-children{margin-top:4px}.wiki-node{margin:2px 0}.wiki-node-button{width:100%;display:flex;align-items:center;gap:8px;padding:7px 10px;background:transparent;border:1px solid transparent;border-radius:8px;color:var(--fg);text-align:left;cursor:pointer}.wiki-node-folder-button{color:var(--fg-dim)}.wiki-node-folder-button:hover,.wiki-node-folder-button.expanded,.wiki-node-page-button:hover{background:var(--bg-elev-2);border-color:var(--border);color:var(--fg)}.wiki-node-page-button{align-items:flex-start}.wiki-node-page-button.selected{background:#3b82f61f;border-color:#3b82f659;box-shadow:inset 2px 0 0 var(--accent)}.wiki-node-icon{width:14px;flex:none;text-align:center;color:var(--fg-dim)}.wiki-node-page-button.selected .wiki-node-icon{color:#93c5fd}.wiki-node-page-button.selected .wiki-node-scope-icon-personal{color:#ddd6fe}.wiki-node-page-button.selected .wiki-node-scope-icon-team{color:#a7f3d0}.wiki-node-content{min-width:0;display:flex;flex:1;flex-direction:column;gap:4px}.wiki-node-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wiki-node-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:11px}.wiki-node-count{margin-left:auto;border:1px solid var(--border);border-radius:999px;padding:0 6px;font-size:11px;color:var(--fg-dim)}.wiki-main{min-width:0;display:flex;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;overflow:hidden}.wiki-main>.wiki-empty-state{width:100%}.wiki-document{width:100%;min-height:0;display:flex;flex-direction:column}.wiki-page-header{position:sticky;top:0;z-index:1;padding:18px 22px 16px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-elev) 0%,rgba(22,27,34,.98) 100%)}.wiki-page-header-main{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.wiki-page-title-block h2{margin:0;font-size:28px;line-height:1.2}.wiki-page-summary{margin:8px 0 0;max-width:72ch;color:var(--fg-dim)}.wiki-page-actions{display:flex;gap:8px;flex:none}.wiki-breadcrumbs ol{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0 0 12px;padding:0}.wiki-breadcrumbs li{display:flex;align-items:center}.wiki-breadcrumbs li+li:before{content:"/";margin-right:8px;color:var(--fg-dim)}.wiki-breadcrumb-button{padding:0;border:0;background:transparent;color:var(--fg-dim);cursor:pointer}.wiki-breadcrumb-button:hover{color:var(--fg);text-decoration:underline}.wiki-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;font-size:12px;color:var(--fg-dim)}.wiki-badge,.wiki-tag,.wiki-meta-item{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:3px 8px;background:var(--bg-elev-2)}.wiki-badge{color:#93c5fd;border-color:#3b82f659}.wiki-scope-badge{display:inline-flex;align-items:center;gap:4px}.wiki-scope-badge-personal{color:#c4b5fd;border-color:#c4b5fd59;background:#c4b5fd14}.wiki-scope-badge-team{color:#6ee7b7;border-color:#6ee7b759;background:#6ee7b714}.wiki-node-scope-icon-personal{color:#c4b5fd}.wiki-node-scope-icon-team{color:#6ee7b7}.wiki-tag{color:var(--fg)}.wiki-meta-path{max-width:100%;overflow:auto;white-space:nowrap}.wiki-document-body{flex:1;min-height:0;overflow:auto}.wiki-article{max-width:76ch;padding:24px 22px 32px}.wiki-empty-state{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:12px;margin:auto;max-width:56ch;padding:32px}.wiki-empty-state.compact{margin:0;max-width:none;padding:20px 12px}.wiki-empty-state h2{margin:0;font-size:20px}.wiki-empty-state p{margin:0;color:var(--fg-dim)}.wiki-empty-state-actions{display:flex;flex-wrap:wrap;gap:8px}@media (max-width: 960px){.wiki-layout{grid-template-columns:1fr}.wiki-sidebar{max-height:50vh}.wiki-page-header-main,.wiki-sidebar-header-row,.wiki-scope-legend{flex-direction:column}.wiki-page-actions{width:100%}.wiki-page-actions .btn{flex:1}}.wiki-edit .row{display:flex;gap:12px;margin-bottom:12px}.wiki-edit input[type=text]{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--fg);padding:8px;border-radius:6px}.wiki-edit label{display:block;width:100%;font-size:12px;color:var(--fg-dim)}.wiki-editor{margin-bottom:16px}.skill-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.skill-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px}.skill-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}.tag{font-size:10px;text-transform:uppercase;padding:2px 6px;border-radius:4px;letter-spacing:.05em;background:var(--bg-elev-2);color:var(--fg-dim)}.tag-bundled{color:#93c5fd}.tag-local{color:#86efac}.tag-global{color:#fcd34d}.settings section{margin-bottom:28px}.settings-field{display:flex;flex-direction:column;gap:6px}.settings-field-label{font-size:12px;color:var(--fg-dim)}.settings select{background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:6px 10px}.row{display:flex;align-items:center;gap:8px}.settings-row{align-items:flex-end}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.4}50%{opacity:1}}.ralph{max-width:760px}.ralph-section{margin-top:24px}.ralph-section h2{font-size:15px;font-weight:600;margin-bottom:10px;color:var(--fg)}.ralph-status-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:8px}.ralph-status-row{display:flex;align-items:center;gap:10px;font-size:14px}.ralph-status-label{min-width:90px;color:var(--text-dim, #888);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.ralph-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em}.ralph-badge--running{background:color-mix(in srgb,var(--accent) 15%,transparent);color:var(--accent)}.ralph-badge--stopped{background:color-mix(in srgb,#888 15%,transparent);color:#888}.ralph-mono{font-family:var(--font-mono, monospace);font-size:13px}.ralph-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:400px}.ralph-controls{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:12px}.ralph-form-row{display:flex;align-items:center;gap:10px;font-size:14px}.ralph-form-row label{min-width:140px;color:var(--text-dim, #888);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.ralph-select,.ralph-input{background:var(--bg-elev-2, var(--bg-elev));border:1px solid var(--border);border-radius:5px;color:var(--fg);font-size:13px;padding:5px 8px;min-width:240px}.ralph-input--narrow{min-width:80px;width:80px}.ralph-select:focus,.ralph-input:focus{outline:2px solid var(--accent);outline-offset:1px}.btn{border:none;border-radius:6px;font-size:13px;font-weight:600;padding:6px 14px;cursor:pointer;transition:opacity .1s;align-self:flex-start}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover:not(:disabled){opacity:.85}.btn-danger{background:#f44336;color:#fff}.btn-danger:hover:not(:disabled){opacity:.85}.ralph-queue-count{font-weight:400;color:var(--text-dim, #888);font-size:13px}.ralph-queue-source{margin-bottom:8px}.ralph-queue{display:flex;flex-direction:column;gap:6px}.ralph-issue{display:block;text-decoration:none;color:inherit;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:10px 12px;transition:background .1s}.ralph-issue:hover{background:var(--bg-elev-2)}.ralph-issue-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}.ralph-issue-number{font-family:var(--font-mono, monospace);font-size:12px;color:var(--text-dim, #888);flex-shrink:0}.ralph-issue-title{font-size:14px;font-weight:500}.ralph-issue-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.ralph-labels{display:flex;flex-wrap:wrap;gap:4px}.ralph-label{font-size:11px;padding:1px 6px;border-radius:8px;background:color-mix(in srgb,#888 12%,transparent);color:var(--fg)}.ralph-label--agent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent)}.ralph-label--triage{background:color-mix(in srgb,#9c27b0 12%,transparent);color:#9c27b0}.ralph-label--urgent{background:color-mix(in srgb,#f44336 12%,transparent);color:#f44336}.link-btn{background:none;border:none;color:var(--accent);font-size:inherit;cursor:pointer;padding:0;text-decoration:underline}