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.
- package/dist/api/server.js +14 -8
- package/dist/api/server.test.js +30 -0
- package/dist/copilot/agents.js +3 -2
- package/dist/copilot/orchestrator.js +48 -10
- package/dist/copilot/orchestrator.test.js +59 -14
- package/dist/copilot/tools.js +183 -7
- package/dist/copilot/tools.memory.test.js +125 -1
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +111 -3
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/memory/index.js +1 -0
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +430 -16
- package/dist/store/db.test.js +452 -10
- package/package.json +1 -1
- package/web/dist/assets/index-BTI_m0OE.css +10 -0
- package/web/dist/assets/{index-DmYLALt0.js → index-D4-uRAi6.js} +52 -52
- package/web/dist/assets/index-D4-uRAi6.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
package/dist/store/db.test.js
CHANGED
|
@@ -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(`
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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, "
|
|
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, "
|
|
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
|
@@ -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}
|