chapterhouse 0.3.2 → 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.
@@ -233,6 +233,72 @@ test("SessionRegistry: close is deferred when session is processing", async () =
233
233
  unblock();
234
234
  await new Promise((r) => setTimeout(r, 5));
235
235
  });
236
+ test("SessionRegistry: pendingClose evicts session within ms of turn completion", async () => {
237
+ let unblock;
238
+ const disconnectLog = [];
239
+ const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
240
+ const t = makeFakeSession();
241
+ t.session.disconnect = async () => { disconnectLog.push(sk); };
242
+ const worker = () => new Promise((res) => { unblock = () => res("done"); });
243
+ return new SessionManager(sk, worker, factory(t.session));
244
+ });
245
+ const m = registry.getOrCreate("session-a");
246
+ await m.ensureSession();
247
+ const { item } = makeDeferred();
248
+ m.enqueue(item);
249
+ await new Promise((r) => setTimeout(r, 0));
250
+ assert.equal(m.isProcessing, true, "should be processing");
251
+ // Close while busy — should set pendingClose, not evict yet
252
+ registry.close("session-a", "explicit-close");
253
+ assert.equal(m.pendingClose, true, "pendingClose must be set");
254
+ assert.ok(registry.get("session-a"), "session must remain in registry until turn finishes");
255
+ assert.equal(disconnectLog.length, 0, "must not disconnect mid-turn");
256
+ // Unblock the turn — session should be evicted within ms
257
+ unblock();
258
+ await new Promise((r) => setTimeout(r, 10));
259
+ assert.ok(!registry.get("session-a"), "session must be evicted after turn completes");
260
+ assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected");
261
+ });
262
+ test("SessionRegistry: pendingClose waits for full queue drain before evicting", async () => {
263
+ let unblock1;
264
+ let unblock2;
265
+ const disconnectLog = [];
266
+ let callCount = 0;
267
+ const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
268
+ const t = makeFakeSession();
269
+ t.session.disconnect = async () => { disconnectLog.push(sk); };
270
+ const worker = () => {
271
+ callCount++;
272
+ if (callCount === 1) {
273
+ return new Promise((res) => { unblock1 = () => res("turn1"); });
274
+ }
275
+ return new Promise((res) => { unblock2 = () => res("turn2"); });
276
+ };
277
+ return new SessionManager(sk, worker, factory(t.session));
278
+ });
279
+ const m = registry.getOrCreate("session-a");
280
+ await m.ensureSession();
281
+ const { item: item1 } = makeDeferred();
282
+ const { item: item2 } = makeDeferred();
283
+ m.enqueue(item1); // starts turn 1 immediately
284
+ await new Promise((r) => setTimeout(r, 0));
285
+ m.enqueue(item2); // queued while turn 1 runs
286
+ assert.equal(m.isProcessing, true, "should be processing turn 1");
287
+ assert.equal(m.queueDepth, 1, "turn 2 should be queued");
288
+ // Close while busy — should set pendingClose
289
+ registry.close("session-a", "explicit-close");
290
+ assert.equal(m.pendingClose, true, "pendingClose must be set");
291
+ // Unblock turn 1 — turn 2 is still queued, session must NOT evict yet
292
+ unblock1();
293
+ await new Promise((r) => setTimeout(r, 10));
294
+ assert.ok(registry.get("session-a"), "session must remain while turn 2 is still queued");
295
+ assert.equal(disconnectLog.length, 0, "must not disconnect until queue fully drains");
296
+ // Unblock turn 2 — queue is now empty, session must evict
297
+ unblock2();
298
+ await new Promise((r) => setTimeout(r, 10));
299
+ assert.ok(!registry.get("session-a"), "session must be evicted after queue fully drains");
300
+ assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected after drain");
301
+ });
236
302
  test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async () => {
237
303
  const SHORT_TTL = 40;
238
304
  const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
@@ -0,0 +1,91 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { mkdirSync as fsMkdirSync, readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync } from "fs";
4
+ import { childLogger } from "../util/logger.js";
5
+ const log = childLogger("workiq-installer");
6
+ export const WORKIQ_SERVER_KEY = "workiq";
7
+ export const WORKIQ_PACKAGE = "@microsoft/workiq";
8
+ export const MCP_CONFIG_PATH = join(homedir(), ".copilot", "mcp-config.json");
9
+ /** Return true if the auto-install feature is active for the given config. */
10
+ export function isWorkiqAutoInstallEnabled(opts) {
11
+ return opts.workiqAutoInstall && opts.entraAuthEnabled && Boolean(opts.entraTenantId);
12
+ }
13
+ /** Parse the raw JSON of a mcp-config.json file. Returns an empty object on any error. */
14
+ export function parseMcpConfigFile(raw) {
15
+ try {
16
+ const parsed = JSON.parse(raw);
17
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
18
+ return parsed;
19
+ }
20
+ return {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ /** Return true if the workiq entry already exists in the config. */
27
+ export function workiqEntryExists(config) {
28
+ return Boolean(config.mcpServers &&
29
+ typeof config.mcpServers === "object" &&
30
+ WORKIQ_SERVER_KEY in config.mcpServers);
31
+ }
32
+ /** Build the workiq MCPStdioServerConfig entry. */
33
+ export function buildWorkiqEntry() {
34
+ return {
35
+ command: "npx",
36
+ args: ["-y", WORKIQ_PACKAGE],
37
+ tools: ["*"],
38
+ };
39
+ }
40
+ /**
41
+ * Ensure the workiq MCP server entry is present in ~/.copilot/mcp-config.json.
42
+ *
43
+ * - Idempotent: if the entry already exists, returns early without writing.
44
+ * - Failure-safe: any I/O error is caught; a structured warn is emitted and
45
+ * the function returns without throwing, so callers (daemon startup) continue.
46
+ *
47
+ * Returns "installed" | "already-present" | "skipped" for test assertions.
48
+ */
49
+ export function ensureWorkiqMcpEntry(options = {}) {
50
+ const configPath = options.configPath ?? MCP_CONFIG_PATH;
51
+ const readFileFn = options.readFile ?? ((p, enc) => fsReadFileSync(p, enc));
52
+ const writeFileFn = options.writeFile ?? ((p, data, enc) => fsWriteFileSync(p, data, enc));
53
+ const mkdirFn = options.mkdirSync ?? ((p, opts) => fsMkdirSync(p, opts));
54
+ try {
55
+ let existingConfig = {};
56
+ try {
57
+ const raw = readFileFn(configPath, "utf-8");
58
+ existingConfig = parseMcpConfigFile(raw);
59
+ }
60
+ catch {
61
+ // File doesn't exist yet — start with empty config.
62
+ }
63
+ if (workiqEntryExists(existingConfig)) {
64
+ log.debug({ configPath }, "workiq MCP entry already present — skipping");
65
+ return "already-present";
66
+ }
67
+ // Ensure the parent directory exists
68
+ const parentDir = join(configPath, "..");
69
+ try {
70
+ mkdirFn(parentDir, { recursive: true });
71
+ }
72
+ catch {
73
+ // Best effort — directory may already exist; writeFile will fail loudly if not.
74
+ }
75
+ const updated = {
76
+ ...existingConfig,
77
+ mcpServers: {
78
+ ...(existingConfig.mcpServers ?? {}),
79
+ [WORKIQ_SERVER_KEY]: buildWorkiqEntry(),
80
+ },
81
+ };
82
+ writeFileFn(configPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
83
+ log.info({ configPath, package: WORKIQ_PACKAGE }, "workiq MCP server auto-installed");
84
+ return "installed";
85
+ }
86
+ catch (err) {
87
+ log.warn({ err: err instanceof Error ? err.message : String(err), configPath }, "workiq MCP auto-install failed — continuing without it");
88
+ return "skipped";
89
+ }
90
+ }
91
+ //# sourceMappingURL=workiq-installer.js.map
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { isWorkiqAutoInstallEnabled, parseMcpConfigFile, workiqEntryExists, buildWorkiqEntry, ensureWorkiqMcpEntry, WORKIQ_SERVER_KEY, WORKIQ_PACKAGE, } from "./workiq-installer.js";
4
+ // ---------------------------------------------------------------------------
5
+ // isWorkiqAutoInstallEnabled
6
+ // ---------------------------------------------------------------------------
7
+ test("isWorkiqAutoInstallEnabled: true when all conditions met", () => {
8
+ assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: true }), true);
9
+ });
10
+ test("isWorkiqAutoInstallEnabled: false when workiqAutoInstall disabled", () => {
11
+ assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: false }), false);
12
+ });
13
+ test("isWorkiqAutoInstallEnabled: false when entraAuthEnabled is false", () => {
14
+ assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: false, entraTenantId: "tenant-id", workiqAutoInstall: true }), false);
15
+ });
16
+ test("isWorkiqAutoInstallEnabled: false when entraTenantId is empty", () => {
17
+ assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "", workiqAutoInstall: true }), false);
18
+ });
19
+ // ---------------------------------------------------------------------------
20
+ // parseMcpConfigFile
21
+ // ---------------------------------------------------------------------------
22
+ test("parseMcpConfigFile: parses valid JSON with mcpServers", () => {
23
+ const raw = JSON.stringify({ mcpServers: { existing: {} } });
24
+ const result = parseMcpConfigFile(raw);
25
+ assert.deepEqual(result, { mcpServers: { existing: {} } });
26
+ });
27
+ test("parseMcpConfigFile: returns empty object for invalid JSON", () => {
28
+ const result = parseMcpConfigFile("not-json");
29
+ assert.deepEqual(result, {});
30
+ });
31
+ test("parseMcpConfigFile: returns empty object for null", () => {
32
+ const result = parseMcpConfigFile("null");
33
+ assert.deepEqual(result, {});
34
+ });
35
+ test("parseMcpConfigFile: returns empty object for array", () => {
36
+ const result = parseMcpConfigFile("[]");
37
+ assert.deepEqual(result, {});
38
+ });
39
+ test("parseMcpConfigFile: parses JSON without mcpServers key", () => {
40
+ const result = parseMcpConfigFile('{"other":"value"}');
41
+ assert.deepEqual(result, { other: "value" });
42
+ });
43
+ // ---------------------------------------------------------------------------
44
+ // workiqEntryExists
45
+ // ---------------------------------------------------------------------------
46
+ test("workiqEntryExists: true when workiq key present", () => {
47
+ assert.equal(workiqEntryExists({ mcpServers: { workiq: {} } }), true);
48
+ });
49
+ test("workiqEntryExists: false when workiq key absent", () => {
50
+ assert.equal(workiqEntryExists({ mcpServers: { other: {} } }), false);
51
+ });
52
+ test("workiqEntryExists: false when mcpServers missing", () => {
53
+ assert.equal(workiqEntryExists({}), false);
54
+ });
55
+ // ---------------------------------------------------------------------------
56
+ // buildWorkiqEntry
57
+ // ---------------------------------------------------------------------------
58
+ test("buildWorkiqEntry: returns correct npx entry shape", () => {
59
+ const entry = buildWorkiqEntry();
60
+ assert.equal(entry.command, "npx");
61
+ assert.deepEqual(entry.args, ["-y", WORKIQ_PACKAGE]);
62
+ assert.deepEqual(entry.tools, ["*"]);
63
+ });
64
+ // ---------------------------------------------------------------------------
65
+ // ensureWorkiqMcpEntry
66
+ // ---------------------------------------------------------------------------
67
+ function makeFs(initialJson) {
68
+ let stored = initialJson ?? null;
69
+ const written = [];
70
+ return {
71
+ readFile: (path, _enc) => {
72
+ if (stored === null)
73
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
74
+ return stored;
75
+ },
76
+ writeFile: (path, data, _enc) => {
77
+ stored = data;
78
+ written.push(data);
79
+ },
80
+ mkdirSync: (_path, _opts) => { },
81
+ written,
82
+ getStored: () => stored,
83
+ };
84
+ }
85
+ test("ensureWorkiqMcpEntry: installs when file does not exist", () => {
86
+ const fs = makeFs();
87
+ const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
88
+ assert.equal(result, "installed");
89
+ const written = JSON.parse(fs.getStored());
90
+ assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY]);
91
+ assert.equal(written.mcpServers[WORKIQ_SERVER_KEY].command, "npx");
92
+ });
93
+ test("ensureWorkiqMcpEntry: installs when file exists without workiq entry", () => {
94
+ const initial = JSON.stringify({ mcpServers: { other: { command: "node", args: [], tools: ["*"] } } });
95
+ const fs = makeFs(initial);
96
+ const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
97
+ assert.equal(result, "installed");
98
+ const written = JSON.parse(fs.getStored());
99
+ assert.ok(written.mcpServers?.other, "existing entry preserved");
100
+ assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY], "workiq entry added");
101
+ });
102
+ test("ensureWorkiqMcpEntry: already-present when workiq entry exists", () => {
103
+ const initial = JSON.stringify({ mcpServers: { workiq: { command: "npx", args: ["-y", "@microsoft/workiq"], tools: ["*"] } } });
104
+ const fs = makeFs(initial);
105
+ const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
106
+ assert.equal(result, "already-present");
107
+ assert.equal(fs.written.length, 0, "no write performed");
108
+ });
109
+ test("ensureWorkiqMcpEntry: idempotent on second call", () => {
110
+ const fs = makeFs();
111
+ ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
112
+ const result2 = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
113
+ assert.equal(result2, "already-present");
114
+ assert.equal(fs.written.length, 1, "only one write total");
115
+ });
116
+ test("ensureWorkiqMcpEntry: returns skipped on write failure", () => {
117
+ const failingWrite = (_p, _d, _e) => {
118
+ throw new Error("EROFS: read-only file system");
119
+ };
120
+ const result = ensureWorkiqMcpEntry({
121
+ configPath: "/fake/mcp-config.json",
122
+ readFile: (_p, _e) => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); },
123
+ writeFile: failingWrite,
124
+ mkdirSync: () => { },
125
+ });
126
+ assert.equal(result, "skipped");
127
+ });
128
+ test("ensureWorkiqMcpEntry: preserves other mcpServers entries on install", () => {
129
+ const initial = JSON.stringify({
130
+ mcpServers: {
131
+ custom: { command: "node", args: ["server.js"], tools: ["myTool"] },
132
+ },
133
+ someOtherKey: true,
134
+ });
135
+ const fs = makeFs(initial);
136
+ ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
137
+ const written = JSON.parse(fs.getStored());
138
+ assert.ok(written.mcpServers?.custom, "existing server preserved");
139
+ assert.equal(written.someOtherKey, true, "unrelated keys preserved");
140
+ });
141
+ test("ensureWorkiqMcpEntry: creates well-formed JSON ending in newline", () => {
142
+ const fs = makeFs();
143
+ ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
144
+ assert.ok(fs.getStored().endsWith("\n"), "file ends with newline");
145
+ // Verify it's valid JSON
146
+ assert.doesNotThrow(() => JSON.parse(fs.getStored()));
147
+ });
148
+ //# sourceMappingURL=workiq-installer.test.js.map
package/dist/daemon.js CHANGED
@@ -18,6 +18,7 @@ import { DecisionsSyncScheduler } from "./squad/mirror.scheduler.js";
18
18
  import { registerShutdownSignals } from "./shutdown-signals.js";
19
19
  import { logger } from "./util/logger.js";
20
20
  import { CHAPTERHOUSE_VERSION } from "./version.js";
21
+ import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
21
22
  const log = logger.child({ module: "daemon" });
22
23
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
23
24
  /**
@@ -119,6 +120,15 @@ async function main() {
119
120
  if (process.env.TELEGRAM_BOT_TOKEN) {
120
121
  log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
121
122
  }
123
+ // Auto-install workiq MCP server when Entra is configured
124
+ if (isWorkiqAutoInstallEnabled({
125
+ entraAuthEnabled: config.entraAuthEnabled,
126
+ entraTenantId: config.entraTenantId,
127
+ workiqAutoInstall: config.workiqAutoInstall,
128
+ })) {
129
+ log.info("Entra auth detected — ensuring workiq MCP server is configured");
130
+ ensureWorkiqMcpEntry();
131
+ }
122
132
  // Start Copilot SDK client
123
133
  log.info("Starting Copilot SDK client");
124
134
  const client = await getClient();
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.2",
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,6 +22,7 @@
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",
@@ -73,6 +74,7 @@
73
74
  "@types/jsonwebtoken": "^9.0.10",
74
75
  "@types/node": "^25.6.0",
75
76
  "husky": "^9.1.7",
77
+ "markdownlint-cli2": "^0.22.1",
76
78
  "tsx": "^4.21.0",
77
79
  "typescript": "^5.9.3"
78
80
  }
@@ -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}.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:disabled{opacity:.5;cursor:not-allowed}.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-actor-icon{font-size:11px;line-height:1}.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.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-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}.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-context-banner{display:flex;align-items:center;gap:8px;padding:6px 16px;background:color-mix(in srgb,var(--accent) 10%,var(--bg-elev));border-bottom:1px solid color-mix(in srgb,var(--accent) 25%,var(--border));font-size:12px;color:var(--fg-dim);flex-shrink:0}.project-context-icon{font-size:13px;flex-shrink:0}.project-context-name{font-weight:600;color:var(--fg);flex-shrink:0}.project-context-path{color:var(--fg-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.project-context-clear{background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;flex-shrink:0;border-radius:4px}.project-context-clear:hover{color:var(--fg);background:var(--bg-hover)}.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}.history-list{list-style:none;padding:0}.history-list li{padding:6px 0;border-bottom:1px solid var(--border)}.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}}