limn-review 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/ai/budget.js +5 -2
  2. package/dist/ai/effort.js +3 -1
  3. package/dist/ai/providers/claude.js +77 -0
  4. package/dist/api/plugins/rate-limit.js +14 -2
  5. package/dist/config.js +10 -4
  6. package/dist/data-dir.js +22 -1
  7. package/dist/db/erase-account.js +26 -0
  8. package/dist/db/export-account.js +42 -0
  9. package/dist/db/instance-id.js +38 -0
  10. package/dist/db/migrations/0139_instance_meta.sql +8 -0
  11. package/dist/db/migrations/0140_reference_doc_wiki.sql +62 -0
  12. package/dist/db/migrations/0141_tracker_ticket_comments.sql +20 -0
  13. package/dist/db/migrations/0142_ticket_review_comments_seen.sql +4 -0
  14. package/dist/db/migrations/0143_reference_doc_figures.sql +25 -0
  15. package/dist/db/migrations/0144_workspace_tracker_notion.sql +3 -0
  16. package/dist/db/migrations/0145_verdict_strictness.sql +14 -0
  17. package/dist/db/migrations/meta/_journal.json +49 -0
  18. package/dist/db/queries.js +3 -0
  19. package/dist/db/retention.js +3 -0
  20. package/dist/db/schema.sqlite.js +168 -1
  21. package/dist/db/ticket-comments-prune.js +19 -0
  22. package/dist/reference-docs/chat-quotes.js +2 -2
  23. package/dist/reference-docs/context.js +6 -1
  24. package/dist/reference-docs/convert.js +37 -7
  25. package/dist/reference-docs/diagram-outline.js +236 -0
  26. package/dist/reference-docs/figure-cache.js +34 -0
  27. package/dist/reference-docs/figure-describer.js +91 -0
  28. package/dist/reference-docs/figure-fetch.js +66 -0
  29. package/dist/reference-docs/figures.js +317 -0
  30. package/dist/reference-docs/library.js +71 -21
  31. package/dist/reference-docs/manager.js +201 -63
  32. package/dist/reference-docs/owner.js +144 -0
  33. package/dist/reference-docs/paths.js +14 -2
  34. package/dist/reference-docs/prompts.js +32 -0
  35. package/dist/reference-docs/review-refs.js +10 -2
  36. package/dist/reference-docs/routes.js +17 -1
  37. package/dist/reference-docs/startup.js +16 -5
  38. package/dist/reference-docs/store.js +106 -1
  39. package/dist/reference-docs/wiki/build.js +179 -0
  40. package/dist/reference-docs/wiki/confluence-storage.js +454 -0
  41. package/dist/reference-docs/wiki/confluence.js +429 -0
  42. package/dist/reference-docs/wiki/credentials.js +166 -0
  43. package/dist/reference-docs/wiki/fake-wiki.js +99 -0
  44. package/dist/reference-docs/wiki/notion-blocks.js +270 -0
  45. package/dist/reference-docs/wiki/notion.js +450 -0
  46. package/dist/reference-docs/wiki/pages.js +103 -0
  47. package/dist/reference-docs/wiki/refresh.js +103 -0
  48. package/dist/reference-docs/wiki/register.js +11 -0
  49. package/dist/reference-docs/wiki/routes.js +199 -0
  50. package/dist/reference-docs/wiki/source.js +91 -0
  51. package/dist/reference-docs/wiki/sync.js +206 -0
  52. package/dist/review/agentic.js +3 -0
  53. package/dist/review/claude-review/auto-post.js +101 -28
  54. package/dist/review/claude-review/auto-settings.js +79 -6
  55. package/dist/review/claude-review/auto-verdict.js +251 -8
  56. package/dist/review/claude-review/not-approved.js +65 -0
  57. package/dist/review/claude-review/pair-consolidate.js +10 -5
  58. package/dist/review/claude-review/pair-view.js +8 -8
  59. package/dist/review/claude-review/pair.js +3 -11
  60. package/dist/review/claude-review/persist.js +2 -0
  61. package/dist/review/claude-review/replies-check.js +25 -1
  62. package/dist/review/claude-review/replies-outcome.js +77 -13
  63. package/dist/review/claude-review/replies-pass.js +33 -1
  64. package/dist/review/claude-review/routes.js +5 -2
  65. package/dist/review/story-docs/agent.js +12 -6
  66. package/dist/review/story-docs/fingerprint.js +18 -1
  67. package/dist/review/story-docs/inputs.js +12 -2
  68. package/dist/review/story-docs/manager.js +67 -31
  69. package/dist/review/story-docs/persist.js +1 -0
  70. package/dist/review/story-docs/prompts.js +92 -20
  71. package/dist/review/story-docs/routes.js +16 -2
  72. package/dist/review/story-docs/shortlist.js +120 -6
  73. package/dist/review/ticket-review/manager.js +9 -2
  74. package/dist/review/ticket-review/persist.js +2 -0
  75. package/dist/review/ticket-review/prepare.js +13 -1
  76. package/dist/review/ticket-review/prompts.js +48 -3
  77. package/dist/review/ticket-review/routes.js +9 -1
  78. package/dist/shared/ai-budget.js +17 -6
  79. package/dist/shared/claude-review.js +28 -2
  80. package/dist/shared/index.js +3 -0
  81. package/dist/shared/notion-tracker.js +169 -0
  82. package/dist/shared/reference-docs-quote.js +26 -0
  83. package/dist/shared/reference-docs-wiki.js +126 -0
  84. package/dist/shared/reference-docs.js +13 -0
  85. package/dist/shared/story-docs-alignment.js +20 -9
  86. package/dist/shared/ticket-comments.js +33 -0
  87. package/dist/shared/ticket-refs.js +10 -0
  88. package/dist/shared/types.js +6 -3
  89. package/dist/tracker/calls.js +19 -4
  90. package/dist/tracker/clip.js +17 -0
  91. package/dist/tracker/comments.js +155 -0
  92. package/dist/tracker/github/reader.js +36 -3
  93. package/dist/tracker/jira/client.js +63 -1
  94. package/dist/tracker/jira/fetch.js +9 -3
  95. package/dist/tracker/linear/client.js +40 -2
  96. package/dist/tracker/links.js +9 -3
  97. package/dist/tracker/manual-links.js +41 -3
  98. package/dist/tracker/notion/adapter.js +33 -0
  99. package/dist/tracker/notion/blocks.js +201 -0
  100. package/dist/tracker/notion/client.js +164 -0
  101. package/dist/tracker/notion/reader.js +300 -0
  102. package/dist/tracker/notion/schema.js +180 -0
  103. package/dist/tracker/peers.js +18 -0
  104. package/dist/tracker/registry.js +4 -1
  105. package/dist/tracker/routes.js +64 -7
  106. package/dist/tracker/settings.js +31 -1
  107. package/dist/tracker/sprint/notion.js +265 -0
  108. package/dist/tracker/sprint/read.js +13 -5
  109. package/dist/tracker/store.js +9 -0
  110. package/dist/tracker/stories.js +2 -13
  111. package/dist/tracker/ticket-source.js +4 -0
  112. package/dist/tracker/worker.js +10 -0
  113. package/package.json +1 -1
  114. package/public/assets/{AiFixTab-DUxlYxj1.js → AiFixTab-v-6yNFQ9.js} +3 -3
  115. package/public/assets/ClaudeReviewTab-DuXBheJq.js +20 -0
  116. package/public/assets/{PrBotBehaviourTab-Cvxgm35A.js → PrBotBehaviourTab-DVJNCXjS.js} +1 -1
  117. package/public/assets/index-C-ZWx5Hz.css +1 -0
  118. package/public/assets/index-ComM_hHI.js +82 -0
  119. package/public/index.html +2 -2
  120. package/public/assets/ClaudeReviewTab-Dvb8i10G.js +0 -20
  121. package/public/assets/index-CTwtlPvo.css +0 -1
  122. package/public/assets/index-WwIuB9lr.js +0 -82
package/dist/ai/budget.js CHANGED
@@ -45,7 +45,7 @@
45
45
  //
46
46
  // One budget per ACCOUNT (= per machine), never per workspace.
47
47
  import { and, eq, gte, inArray } from 'drizzle-orm';
48
- import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
48
+ import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, budgetSettingsInForce, rollingSpend, } from '../shared/index.js';
49
49
  import { db, schema } from '../db/client.js';
50
50
  import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
51
51
  import { getActiveProvider, getProvider } from './provider.js';
@@ -288,7 +288,10 @@ export async function aiBudgetSnapshot(accountId, opts = {}) {
288
288
  // the ledger — the named one (a manual run's per-run choice), else the default (active) one.
289
289
  const provider = opts.provider ? getProvider(opts.provider) : getActiveProvider();
290
290
  const route = provider.credentialRoute();
291
- const { settings, isDefault } = await readAiBudgetSettings(accountId);
291
+ const stored = await readAiBudgetSettings(accountId);
292
+ const isDefault = stored.isDefault;
293
+ // On by default (a share of the week); a never-set row on an API key applies no US$ cap.
294
+ const settings = budgetSettingsInForce(stored.settings, isDefault, route);
292
295
  const probe = opts.probe ?? 'when_on';
293
296
  if (route === 'login' && (probe === 'always' || (probe === 'when_on' && settings.enabled)))
294
297
  kickUsageProbe(nowMs, provider);
package/dist/ai/effort.js CHANGED
@@ -11,7 +11,9 @@
11
11
  // exactly the same runs as the Settings control (REVIEW_EFFORT IS the control, locked), so "Set by
12
12
  // REVIEW_EFFORT" under it is the whole story. Every other agent kind (CI review, ticket review,
13
13
  // diagrams, Check threads, chats, Resolve with Claude) runs at its provider's own fixed depth
14
- // (`defaultRunEffort`), which reads no environment.
14
+ // (`defaultRunEffort`), which reads no environment — except Check against docs, whose read is pinned to
15
+ // 'low' on both providers (review/story-docs/prompts.ts STORY_DOCS_EFFORT: the sections' text is in the
16
+ // prompt, so it is reading, not search). Still a fixed depth; this setting does not reach it.
15
17
  //
16
18
  // A worktree run sends the level. A diff-only review runs ONE STEP LOWER, but never below what Default
17
19
  // runs it at — unless the chosen level is itself below that, when it runs at the chosen level
@@ -273,6 +273,82 @@ async function agentComplete(model, opts, credential) {
273
273
  }
274
274
  return { text: trimmed, usage: { inputTokens, outputTokens } };
275
275
  }
276
+ // ---- describeImage (vision; docs/REFERENCE-DOCS.md § Figures) ------------------------------------
277
+ //
278
+ // The cheap completion's twin with ONE image beside the prompt: the same ladder, the same request shape
279
+ // (low effort + thinking headroom on the adaptive model), no tools, one turn. On the login the image
280
+ // rides a streamed user message (the Agent SDK's only way to send content blocks); on the key it is a
281
+ // plain Messages API call.
282
+ async function describeImage(req) {
283
+ const model = req.model ?? CLAUDE_MODEL_FOR_ROLE.fast;
284
+ const credential = resolveAiCredential();
285
+ const route = cheapRoute(credential);
286
+ if (route === 'none')
287
+ throw new Error(NO_CLAUDE_AUTH_MESSAGE);
288
+ const content = [
289
+ { type: 'image', source: { type: 'base64', media_type: req.image.mime, data: req.image.base64 } },
290
+ { type: 'text', text: req.prompt },
291
+ ];
292
+ const shape = cheapRequestShape(model, req.maxTokens ?? 400);
293
+ if (route === 'api' && credential.route === 'api_key') {
294
+ const { default: Anthropic } = await loadAnthropicSdk();
295
+ const client = new Anthropic({ apiKey: credential.apiKey });
296
+ const resp = await client.messages.create({
297
+ model,
298
+ max_tokens: shape.maxTokens,
299
+ ...(shape.effort ? { output_config: { effort: shape.effort } } : {}),
300
+ system: req.system,
301
+ messages: [{ role: 'user', content }],
302
+ });
303
+ const blocks = resp.content;
304
+ const text = (Array.isArray(blocks) ? blocks : [])
305
+ .map((b) => {
306
+ const block = b;
307
+ return block?.type === 'text' && typeof block.text === 'string' ? block.text : '';
308
+ })
309
+ .join('');
310
+ return { text: text.trim(), usage: { inputTokens: resp.usage.input_tokens, outputTokens: resp.usage.output_tokens }, model };
311
+ }
312
+ const { query } = await loadAgentSdk();
313
+ async function* oneMessage() {
314
+ yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: '' };
315
+ }
316
+ const q = query({
317
+ prompt: oneMessage(),
318
+ options: {
319
+ model,
320
+ ...(shape.effort ? { effort: shape.effort } : {}),
321
+ systemPrompt: req.system,
322
+ allowedTools: [],
323
+ maxTurns: 1,
324
+ permissionMode: 'bypassPermissions',
325
+ settingSources: [],
326
+ ...ISOLATED_MCP,
327
+ hooks: { PreToolUse: [ownToolsOnlyHook([])] },
328
+ ...claudeExecutableOptions(),
329
+ env: agentSdkEnv(process.env, credential),
330
+ },
331
+ });
332
+ let text = '';
333
+ let inputTokens = 0;
334
+ let outputTokens = 0;
335
+ for await (const message of q) {
336
+ if (message.type === 'assistant') {
337
+ const inner = (message.message ?? {});
338
+ for (const b of inner.content ?? [])
339
+ if (b?.type === 'text' && typeof b.text === 'string')
340
+ text += b.text;
341
+ }
342
+ else if (message.type === 'result') {
343
+ const usage = (message.usage ?? {});
344
+ inputTokens = usage.input_tokens ?? inputTokens;
345
+ outputTokens = usage.output_tokens ?? outputTokens;
346
+ }
347
+ }
348
+ if (!text.trim())
349
+ throw new Error('The image description came back empty.');
350
+ return { text: text.trim(), usage: { inputTokens, outputTokens }, model };
351
+ }
276
352
  // ---- detect ----------------------------------------------------------------------------------
277
353
  async function detect() {
278
354
  if (!config.aiEnabled) {
@@ -346,6 +422,7 @@ export const claudeProvider = {
346
422
  resolveModel,
347
423
  runAgent,
348
424
  complete,
425
+ describeImage,
349
426
  detect,
350
427
  credentialRoute: () => resolveAiCredential().route,
351
428
  usage: {
@@ -116,11 +116,13 @@ function tierFor(method, path) {
116
116
  // the customer's Jira with the saved token.
117
117
  // GET /api/workspaces/:id/tracker/linear-check the Linear connection check — one call to
118
118
  // Linear with the saved key.
119
- // All three take the 60/min `search` bucket, the "one upstream call per request" tier. The plain
119
+ // GET /api/workspaces/:id/tracker/notion-schema the Notion mapping read — the saved database's
120
+ // schema (+ ≤ 3 related databases) with the saved token.
121
+ // All four take the 60/min `search` bucket, the "one upstream call per request" tier. The plain
120
122
  // `GET …/tracker` is DB-only and stays on `read` with the rest of the family.
121
123
  if (method === 'PUT' && /^\/api\/workspaces\/\d+\/tracker$/.test(path))
122
124
  return [TIERS.search, TIERS.read];
123
- if (!mutating && /^\/api\/workspaces\/\d+\/tracker\/(jira-fields|linear-check)$/.test(path)) {
125
+ if (!mutating && /^\/api\/workspaces\/\d+\/tracker\/(jira-fields|linear-check|notion-schema)$/.test(path)) {
124
126
  return [TIERS.search, TIERS.read];
125
127
  }
126
128
  // SONARCLOUD (sonar/routes.ts): "Test connection" calls SonarCloud with the saved token (one call
@@ -128,6 +130,10 @@ function tierFor(method, path) {
128
130
  // `…/sonar` are DB-only and fall to `read` on the next line, decided rather than inherited.
129
131
  if (method === 'POST' && /^\/api\/workspaces\/\d+\/sonar\/check$/.test(path))
130
132
  return [TIERS.search, TIERS.read];
133
+ // WIKI CREDENTIALS (reference-docs/wiki/routes.ts): "Test connection" is one read of the customer's
134
+ // Confluence with the saved token → `search`. GET/PUT `…/wiki-credentials` are DB-only → `read` below.
135
+ if (method === 'POST' && /^\/api\/workspaces\/\d+\/wiki-credentials\/check$/.test(path))
136
+ return [TIERS.search, TIERS.read];
131
137
  // RUN AUTO REVIEWS NOW / RETRY (review/claude-review/auto-settings.ts): one press can queue up to a
132
138
  // day's cap of agentic model runs on the reader's own Claude or Codex — `ai` + `aiHourly`, decided
133
139
  // ABOVE the family's `read` line, by exact shape. The GET/PUT `…/auto-review` stay `read`.
@@ -420,6 +426,12 @@ function tierFor(method, path) {
420
426
  }
421
427
  if (method === 'POST' && /^\/api\/docs\/\d+\/rebuild$/.test(path))
422
428
  return [TIERS.ai, TIERS.aiHourly];
429
+ // WIKI SOURCES (reference-docs/wiki/routes.ts): adding a Confluence page and "Refresh now" read the
430
+ // CUSTOMER'S Confluence quota with the saved token (a page, its tree, a CQL search) and may run the
431
+ // build's model → search + ai + ai_hourly. (The credentials routes sit with the workspace family above.)
432
+ if (method === 'POST' && (path === '/api/docs/wiki' || /^\/api\/docs\/\d+\/refresh$/.test(path))) {
433
+ return [TIERS.search, TIERS.ai, TIERS.aiHourly];
434
+ }
423
435
  if (path === '/api/docs' || path.startsWith('/api/docs/'))
424
436
  return [TIERS.read];
425
437
  if (/^\/api\/workspaces\/\d+\/docs-settings$/.test(path))
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { isAbsolute, join, resolve } from 'node:path';
3
- import { migrateLegacyDataDir, resolveDataDir } from './data-dir.js';
3
+ import { defaultDocsDir, migrateLegacyDataDir, resolveDataDir } from './data-dir.js';
4
4
  import { SPRINT_PATHS_BUDGET_USD_DEFAULT, SPRINT_PATHS_DAILY_CAP_DEFAULT, SPRINT_PATHS_MAX_TURNS_DEFAULT, } from './shared/index.js';
5
5
  // apps/backend as the base for relative paths regardless of cwd.
6
6
  const backendRoot = resolve(import.meta.dirname, '..');
@@ -53,6 +53,7 @@ if (!process.env.VITEST) {
53
53
  // GitHub rate limit and contend on the SQLite write lock. DATABASE_URL / --db
54
54
  // override this; a relative override resolves under apps/backend.
55
55
  const rawDbUrl = process.env.DATABASE_URL ?? './data/pierre-review.sqlite';
56
+ const dbPath = isAbsolute(rawDbUrl) ? rawDbUrl : resolve(backendRoot, rawDbUrl);
56
57
  // ---- Phase 2 real-time sync: adaptive polling (see docs/REALTIME-SYNC.md) ----
57
58
  // DEFAULT ON. This is the PRIMARY sync strategy: cadence by activity bucket, plus a
58
59
  // conditional REST probe whose 304 costs no rate limit, so it's both fresher on active repos
@@ -136,7 +137,7 @@ export const config = {
136
137
  persistBodies: process.env.PERSIST_BODIES === 'true',
137
138
  port: intFromEnv('PORT', 4000),
138
139
  host: process.env.HOST ?? '127.0.0.1',
139
- dbPath: isAbsolute(rawDbUrl) ? rawDbUrl : resolve(backendRoot, rawDbUrl),
140
+ dbPath,
140
141
  backfillDays: intFromEnv('BACKFILL_DAYS', 90),
141
142
  // First sync runs in two phases: a fast "foreground" window (matching the
142
143
  // default timeline range) so the board is usable in seconds, then the rest of
@@ -419,8 +420,13 @@ export const config = {
419
420
  sequenceDiagramDailyCap: intFromEnv('SEQUENCE_DIAGRAM_DAILY_CAP', 30),
420
421
  // ---- Reference docs (the per-workspace library — reference-docs/, docs/REFERENCE-DOCS.md) ----
421
422
  // Where the markdown tree lives: `<docsDir>/<accountId>/<workspaceId>/library/` (+ `sources/`).
422
- // LIMN_DOCS_DIR overrides; default `<dataDir>/docs`.
423
- docsDir: process.env.LIMN_DOCS_DIR ?? join(resolveDataDir(), 'docs'),
423
+ // LIMN_DOCS_DIR overrides. Default: ONE ROOT PER DATABASE (data-dir.ts `defaultDocsDir`) —
424
+ // `<dataDir>/docs` for the data directory's own DB, `<dataDir>/docs/db-<hash>` for any other.
425
+ docsDir: process.env.LIMN_DOCS_DIR?.trim() || defaultDocsDir(dbPath, resolveDataDir()),
426
+ // The root every database shared before the split: a DB with another root moves its own
427
+ // workspace folders out of it ONCE (reference-docs/owner.ts `migrateLegacyDocs`). Never when
428
+ // LIMN_DOCS_DIR is set — the user chose where the library lives.
429
+ legacyDocsDir: process.env.LIMN_DOCS_DIR?.trim() ? null : join(resolveDataDir(), 'docs'),
424
430
  // One Sonnet 5 build per document: its OWN spend and turn caps (a long PDF is read in 20-page
425
431
  // slices and written out section by section). Env only — no route sets them.
426
432
  docsBuildBudgetUsd: floatFromEnv('DOCS_BUILD_BUDGET_USD', 6),
package/dist/data-dir.js CHANGED
@@ -30,6 +30,7 @@
30
30
  // user's history stranded. Instead the next boot simply tries the move again.
31
31
  //
32
32
  // No imports outside `node:*`, so cli.ts can load it before config.ts reads the environment.
33
+ import { createHash } from 'node:crypto';
33
34
  import { existsSync, renameSync } from 'node:fs';
34
35
  import { homedir } from 'node:os';
35
36
  import { isAbsolute, join, relative, resolve } from 'node:path';
@@ -52,7 +53,27 @@ export function resolveDataDir(env = process.env, home = homedir()) {
52
53
  return target;
53
54
  }
54
55
  /** The database file the CLI and the server open by default (the name predates the rename). */
55
- const DB_FILE = 'pierre-review.sqlite';
56
+ export const DB_FILE = 'pierre-review.sqlite';
57
+ /**
58
+ * THE REFERENCE-DOCS ROOT FOR ONE DATABASE (when LIMN_DOCS_DIR is not set). ⚠ Two databases never
59
+ * share one: each one's boot janitor deletes every docs folder its own rows do not account for, so a
60
+ * shared root let a second backend (a worktree's `pnpm dev`, the installed CLI) wipe the first one's
61
+ * library (docs/REFERENCE-DOCS.md § On disk).
62
+ *
63
+ * - The data directory's own database (`<dataDir>/pierre-review.sqlite`, the installed CLI's
64
+ * default) keeps `<dataDir>/docs` — where every installed library already is; nothing moves.
65
+ * - Any other database (`pnpm dev`'s `apps/backend/data/pierre-review.sqlite`, a `--db`, a
66
+ * DATABASE_URL) gets `<dataDir>/docs/db-<12 hex of sha256(absolute DB path)>`. The `db-` prefix
67
+ * keeps it non-numeric, so the default root's janitor — which only walks `<accountId>` folders,
68
+ * in this version and every earlier one — never enters it.
69
+ */
70
+ export function defaultDocsDir(dbPath, dataDir) {
71
+ const abs = resolve(dbPath);
72
+ const root = join(dataDir, 'docs');
73
+ if (abs === join(resolve(dataDir), DB_FILE))
74
+ return root;
75
+ return join(root, `db-${createHash('sha256').update(abs).digest('hex').slice(0, 12)}`);
76
+ }
56
77
  function isInside(dir, path) {
57
78
  const rel = relative(dir, path);
58
79
  return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel));
@@ -162,6 +162,11 @@ export async function eraseAccountData(accountId) {
162
162
  // hostname AND the sealed API token. No FKs on the first two, so the explicit deletes are the
163
163
  // whole guarantee; workspace_trackers cascades from workspaces too, and is explicit anyway.
164
164
  await tx.delete(schema.trackerTickets).where(eq(schema.trackerTickets.accountId, accountId)).execute();
165
+ // The tickets' comments (migration 0141): people's words from the tracker. No FKs.
166
+ await tx
167
+ .delete(schema.trackerTicketComments)
168
+ .where(eq(schema.trackerTicketComments.accountId, accountId))
169
+ .execute();
165
170
  await tx.delete(schema.jiraAcFields).where(eq(schema.jiraAcFields.accountId, accountId)).execute();
166
171
  await tx.delete(schema.workspaceTrackers).where(eq(schema.workspaceTrackers.accountId, accountId)).execute();
167
172
  // SonarCloud (migration 0114 / pg 0101): the workspace rows hold the SEALED token; the snapshots
@@ -178,6 +183,12 @@ export async function eraseAccountData(accountId) {
178
183
  // person typed, answers they paid for). Both cascade from workspaces too, and are explicit anyway.
179
184
  // The FILES (the markdown tree and the stored uploads) are removed after the transaction below.
180
185
  await tx.delete(schema.referenceDocChatMessages).where(eq(schema.referenceDocChatMessages.accountId, accountId)).execute();
186
+ // Wiki sources (migration 0140): the page rows (before their documents — composite FK) and the
187
+ // sealed wiki credential. Both cascade too, and are explicit anyway.
188
+ await tx.delete(schema.referenceDocWikiPages).where(eq(schema.referenceDocWikiPages.accountId, accountId)).execute();
189
+ await tx.delete(schema.workspaceWikiCredentials).where(eq(schema.workspaceWikiCredentials.accountId, accountId)).execute();
190
+ // Figure descriptions (migration 0143): the account's image-description cache.
191
+ await tx.delete(schema.referenceDocFigureDescriptions).where(eq(schema.referenceDocFigureDescriptions.accountId, accountId)).execute();
181
192
  await tx.delete(schema.referenceDocs).where(eq(schema.referenceDocs.accountId, accountId)).execute();
182
193
  // Story docs checks (migration 0121 / pg 0108): cascade from workspaces too, and are explicit anyway.
183
194
  await tx.delete(schema.storyDocsAlignments).where(eq(schema.storyDocsAlignments.accountId, accountId)).execute();
@@ -328,6 +339,8 @@ export function accountScopedTables() {
328
339
  // The issue tracker (migration 0088 / pg 0075). All three carry their own accountId and are
329
340
  // erased explicitly above; `workspaceTrackers` holds a credential.
330
341
  { name: 'trackerTickets', col: schema.trackerTickets.accountId, table: schema.trackerTickets },
342
+ // Ticket comments (migration 0141), erased explicitly above.
343
+ { name: 'trackerTicketComments', col: schema.trackerTicketComments.accountId, table: schema.trackerTicketComments },
331
344
  { name: 'jiraAcFields', col: schema.jiraAcFields.accountId, table: schema.jiraAcFields },
332
345
  { name: 'workspaceTrackers', col: schema.workspaceTrackers.accountId, table: schema.workspaceTrackers },
333
346
  // SonarCloud (migration 0114 / pg 0101), erased explicitly above; `workspaceSonar` holds a credential.
@@ -344,6 +357,19 @@ export function accountScopedTables() {
344
357
  col: schema.referenceDocChatMessages.accountId,
345
358
  table: schema.referenceDocChatMessages,
346
359
  },
360
+ // Wiki sources (migration 0140), erased explicitly above; `workspaceWikiCredentials` holds a credential.
361
+ { name: 'referenceDocWikiPages', col: schema.referenceDocWikiPages.accountId, table: schema.referenceDocWikiPages },
362
+ // Figure descriptions (migration 0143), erased explicitly above.
363
+ {
364
+ name: 'referenceDocFigureDescriptions',
365
+ col: schema.referenceDocFigureDescriptions.accountId,
366
+ table: schema.referenceDocFigureDescriptions,
367
+ },
368
+ {
369
+ name: 'workspaceWikiCredentials',
370
+ col: schema.workspaceWikiCredentials.accountId,
371
+ table: schema.workspaceWikiCredentials,
372
+ },
347
373
  // Story docs checks (migration 0121 / pg 0108), erased explicitly above.
348
374
  { name: 'storyDocsAlignments', col: schema.storyDocsAlignments.accountId, table: schema.storyDocsAlignments },
349
375
  // Chronology's settings history (migration 0096 / pg 0083), erased explicitly above.
@@ -84,6 +84,8 @@ export async function exportAccountData(accountId) {
84
84
  matchScope: schema.workspaceTrackers.matchScope,
85
85
  email: schema.workspaceTrackers.authEmail,
86
86
  hasToken: schema.workspaceTrackers.authToken,
87
+ // The Notion property mapping (not a secret).
88
+ notionConfig: schema.workspaceTrackers.notionConfig,
87
89
  updatedAt: schema.workspaceTrackers.updatedAt,
88
90
  })
89
91
  .from(schema.workspaceTrackers)
@@ -116,12 +118,48 @@ export async function exportAccountData(accountId) {
116
118
  summary: schema.referenceDocs.summary,
117
119
  topics: schema.referenceDocs.topics,
118
120
  sections: schema.referenceDocs.sections,
121
+ wiki: schema.referenceDocs.wiki,
119
122
  createdAt: schema.referenceDocs.createdAt,
120
123
  builtAt: schema.referenceDocs.builtAt,
121
124
  })
122
125
  .from(schema.referenceDocs)
123
126
  .where(eq(schema.referenceDocs.accountId, accountId))
124
127
  .execute();
128
+ // Wiki sources (migration 0140): the pages of each wiki document, and the wiki credential WITHOUT its token.
129
+ const wikiPageRows = await db
130
+ .select({
131
+ docId: schema.referenceDocWikiPages.docId,
132
+ pageId: schema.referenceDocWikiPages.pageId,
133
+ title: schema.referenceDocWikiPages.title,
134
+ version: schema.referenceDocWikiPages.version,
135
+ lastEditedAt: schema.referenceDocWikiPages.lastEditedAt,
136
+ lastFetchedAt: schema.referenceDocWikiPages.lastFetchedAt,
137
+ })
138
+ .from(schema.referenceDocWikiPages)
139
+ .where(eq(schema.referenceDocWikiPages.accountId, accountId))
140
+ .execute();
141
+ const wikiCredentialRows = await db
142
+ .select({
143
+ workspaceId: schema.workspaceWikiCredentials.workspaceId,
144
+ provider: schema.workspaceWikiCredentials.provider,
145
+ siteUrl: schema.workspaceWikiCredentials.siteUrl,
146
+ email: schema.workspaceWikiCredentials.authEmail,
147
+ hasToken: schema.workspaceWikiCredentials.authToken,
148
+ updatedAt: schema.workspaceWikiCredentials.updatedAt,
149
+ })
150
+ .from(schema.workspaceWikiCredentials)
151
+ .where(eq(schema.workspaceWikiCredentials.accountId, accountId))
152
+ .execute();
153
+ const figureRows = await db
154
+ .select({
155
+ imageHash: schema.referenceDocFigureDescriptions.imageHash,
156
+ description: schema.referenceDocFigureDescriptions.description,
157
+ model: schema.referenceDocFigureDescriptions.model,
158
+ createdAt: schema.referenceDocFigureDescriptions.createdAt,
159
+ })
160
+ .from(schema.referenceDocFigureDescriptions)
161
+ .where(eq(schema.referenceDocFigureDescriptions.accountId, accountId))
162
+ .execute();
125
163
  const docChatRows = await db
126
164
  .select({
127
165
  workspaceId: schema.referenceDocChatMessages.workspaceId,
@@ -275,6 +313,10 @@ export async function exportAccountData(accountId) {
275
313
  workspaceTrackers: trackerRows.map((t) => ({ ...t, hasToken: t.hasToken != null && t.hasToken !== '' })),
276
314
  workspaceSonar: sonarRows.map((t) => ({ ...t, hasToken: t.hasToken != null && t.hasToken !== '' })),
277
315
  referenceDocs: referenceDocRows,
316
+ referenceDocWikiPages: wikiPageRows,
317
+ // ⚠ PRESENCE ONLY — never the stored token, in any form.
318
+ workspaceWikiCredentials: wikiCredentialRows.map((t) => ({ ...t, hasToken: t.hasToken != null && t.hasToken !== '' })),
319
+ referenceDocFigureDescriptions: figureRows,
278
320
  repoRelations: repoRelationRows,
279
321
  sprintCodeJudgements: capped('sprintCodeJudgements', codeJudgementRows),
280
322
  referenceDocChatMessages: capped('referenceDocChatMessages', docChatRows),
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { eq } from 'drizzle-orm';
3
+ import { db, schema } from './client.js';
4
+ // THIS DATABASE'S ID (migration 0139, `instance_meta`). A random id written ONCE, the first time
5
+ // anything asks, and read back from then on. It names the database FILE, not a path: the file can be
6
+ // moved (the ~/.pierre-review → ~/.limn rename) and keeps its id; a second database on the same
7
+ // machine (a worktree's dev server, the installed CLI) always has a different one. The reference-docs
8
+ // janitor writes it into each workspace docs folder as `.owner` and never deletes a folder holding
9
+ // another id (reference-docs/owner.ts).
10
+ const KEY = 'instance_id';
11
+ let cached = null;
12
+ async function readOrCreate() {
13
+ const t = schema.instanceMeta;
14
+ const read = async () => {
15
+ const rows = await db.select({ value: t.value }).from(t).where(eq(t.key, KEY)).execute();
16
+ return rows[0]?.value ?? null;
17
+ };
18
+ const existing = await read();
19
+ if (existing)
20
+ return existing;
21
+ // Two first callers race to the insert; the loser's row is dropped and both read the winner's.
22
+ await db.insert(t).values({ key: KEY, value: randomUUID() }).onConflictDoNothing().execute();
23
+ const id = await read();
24
+ if (!id)
25
+ throw new Error('instance_meta: instance_id could not be written');
26
+ return id;
27
+ }
28
+ /** This database's random id (created on first call, cached for the process). */
29
+ export function getInstanceId() {
30
+ if (!cached) {
31
+ cached = readOrCreate().catch((err) => {
32
+ cached = null;
33
+ throw err;
34
+ });
35
+ }
36
+ return cached;
37
+ }
38
+ //# sourceMappingURL=instance-id.js.map
@@ -0,0 +1,8 @@
1
+ -- THIS DATABASE'S OWN FACTS (key → value), not an account's. One row so far: `instance_id`, a random
2
+ -- id written once (db/instance-id.ts) that names THIS database file wherever it is moved. The
3
+ -- reference-docs janitor writes it into every workspace docs folder it creates (`.owner`) and never
4
+ -- deletes a folder another database's id is written in (docs/REFERENCE-DOCS.md § On disk).
5
+ CREATE TABLE `instance_meta` (
6
+ `key` text PRIMARY KEY NOT NULL,
7
+ `value` text NOT NULL
8
+ );
@@ -0,0 +1,62 @@
1
+ -- WIKI SOURCES FOR THE REFERENCE LIBRARY (2026-10-28; docs/REFERENCE-DOCS.md § Wiki sources). CORE,
2
+ -- free, LOCAL ONLY (agentic). A Confluence page tree becomes ONE document; a refresh re-reads only the
3
+ -- changed pages and re-summarises only the changed sections. Self-contained and additive.
4
+ --
5
+ -- reference_docs.wiki_json NEW, nullable JSON (ReferenceDocWikiState): the tree's root,
6
+ -- depth and refresh state. NULL for every other source.
7
+ -- reference_docs.summary_cache_json NEW, nullable JSON (ReferenceDocSummaryCache): section hash →
8
+ -- summary, so an unchanged section is never summarised again.
9
+ -- reference_docs (id, account_id) NEW unique index: the composite FK target below.
10
+ -- workspaces.docs_used_at NEW, nullable: the library was last used — the refresh's
11
+ -- demand gate (7 days).
12
+ -- reference_doc_wiki_pages NEW: one row per page of a wiki document (version, hash, last
13
+ -- fetch, last error). Composite FK onto the document; cascades.
14
+ -- workspace_wiki_credentials NEW: a sealed wiki credential per (workspace, provider).
15
+ -- Composite FK onto the workspace; cascades.
16
+ -- Both new tables carry account_id and join accountScopedTables() (erased, exported without the token).
17
+ ALTER TABLE `reference_docs` ADD COLUMN `wiki_json` text;
18
+ --> statement-breakpoint
19
+ ALTER TABLE `reference_docs` ADD COLUMN `summary_cache_json` text;
20
+ --> statement-breakpoint
21
+ ALTER TABLE `workspaces` ADD COLUMN `docs_used_at` integer;
22
+ --> statement-breakpoint
23
+ CREATE UNIQUE INDEX `reference_docs_id_account` ON `reference_docs` (`id`,`account_id`);
24
+ --> statement-breakpoint
25
+ CREATE TABLE `reference_doc_wiki_pages` (
26
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
27
+ `account_id` integer NOT NULL,
28
+ `doc_id` integer NOT NULL,
29
+ `page_id` text NOT NULL,
30
+ `parent_page_id` text,
31
+ `depth` integer DEFAULT 0 NOT NULL,
32
+ `position` integer DEFAULT 0 NOT NULL,
33
+ `title` text NOT NULL,
34
+ `version` integer,
35
+ `last_edited_at` integer,
36
+ `content_hash` text,
37
+ `last_fetched_at` integer,
38
+ `last_error` text,
39
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
40
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
41
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
42
+ CONSTRAINT `reference_doc_wiki_pages_doc_account_fk` FOREIGN KEY (`doc_id`,`account_id`) REFERENCES `reference_docs`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
43
+ );
44
+ --> statement-breakpoint
45
+ CREATE UNIQUE INDEX `reference_doc_wiki_pages_doc_page` ON `reference_doc_wiki_pages` (`doc_id`,`page_id`);
46
+ --> statement-breakpoint
47
+ CREATE INDEX `reference_doc_wiki_pages_account_doc` ON `reference_doc_wiki_pages` (`account_id`,`doc_id`);
48
+ --> statement-breakpoint
49
+ CREATE TABLE `workspace_wiki_credentials` (
50
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
51
+ `account_id` integer NOT NULL,
52
+ `workspace_id` integer NOT NULL,
53
+ `provider` text NOT NULL,
54
+ `site_url` text,
55
+ `auth_email` text,
56
+ `auth_token` text,
57
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
58
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
59
+ CONSTRAINT `workspace_wiki_credentials_workspace_account_fk` FOREIGN KEY (`workspace_id`,`account_id`) REFERENCES `workspaces`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
60
+ );
61
+ --> statement-breakpoint
62
+ CREATE UNIQUE INDEX `workspace_wiki_credentials_workspace_provider` ON `workspace_wiki_credentials` (`account_id`,`workspace_id`,`provider`);
@@ -0,0 +1,20 @@
1
+ -- TICKET COMMENTS (docs/TRACKERS.md § Ticket comments). The discussion under a tracker ticket, one row
2
+ -- per (account, provider, site, key, comment) — per TICKET, never per PR. The worker keeps the newest 20
3
+ -- per ticket from the issue read it already makes; bots are stored with `is_bot` and never shown.
4
+ CREATE TABLE `tracker_ticket_comments` (
5
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
6
+ `account_id` integer NOT NULL,
7
+ `provider` text NOT NULL,
8
+ `api_root` text NOT NULL,
9
+ `issue_key` text NOT NULL,
10
+ `comment_id` text NOT NULL,
11
+ `author_name` text DEFAULT '' NOT NULL,
12
+ `author_id` text,
13
+ `is_bot` integer DEFAULT false NOT NULL,
14
+ `body` text NOT NULL,
15
+ `created_at` integer NOT NULL,
16
+ `updated_at` integer,
17
+ `fetched_at` integer NOT NULL
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE UNIQUE INDEX `tracker_ticket_comments_ticket_comment` ON `tracker_ticket_comments` (`account_id`,`provider`,`api_root`,`issue_key`,`comment_id`);
@@ -0,0 +1,4 @@
1
+ -- What a ticket review run was given of the ticket's comments: {ids, count, hash} of the people's
2
+ -- comments (docs/TRACKERS.md § Ticket comments). NOT part of the run's fingerprint: a new comment never
3
+ -- makes a run stale; the state route counts the comments the run did not see.
4
+ ALTER TABLE `ticket_reviews` ADD `comments_seen` text;
@@ -0,0 +1,25 @@
1
+ -- FIGURES AS TEXT + NOTION SOURCES (2026-10-28; docs/REFERENCE-DOCS.md § Figures, § Wiki sources →
2
+ -- Notion). CORE, free, LOCAL ONLY (agentic). Self-contained and additive. Notion needs no schema: its
3
+ -- token is a `workspace_wiki_credentials` row with provider 'notion' (0140).
4
+ --
5
+ -- reference_docs.figures_json NEW, nullable JSON (ReferenceDocFigures): the latest build's
6
+ -- figures — described / outlined / kept / pending — and the one
7
+ -- sentence when they could not be described here.
8
+ -- reference_doc_figure_descriptions NEW: the AI image-description CACHE, one row per (account,
9
+ -- image sha256), so an unchanged image is never described again.
10
+ -- The table carries account_id, joins accountScopedTables(), the erasure and the export.
11
+ ALTER TABLE `reference_docs` ADD COLUMN `figures_json` text;
12
+ --> statement-breakpoint
13
+ CREATE TABLE `reference_doc_figure_descriptions` (
14
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
15
+ `account_id` integer NOT NULL,
16
+ `image_hash` text NOT NULL,
17
+ `description` text NOT NULL,
18
+ `provider` text,
19
+ `model` text,
20
+ `cost_usd` real,
21
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
22
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE UNIQUE INDEX `reference_doc_figure_descriptions_account_hash` ON `reference_doc_figure_descriptions` (`account_id`,`image_hash`);
@@ -0,0 +1,3 @@
1
+ -- NOTION AS AN ISSUE TRACKER (docs/TRACKERS.md § Notion). The workspace's Notion property mapping
2
+ -- (shared `NotionTrackerMapping`, JSON) beside the one tracker row it belongs to. Additive; NULL = none.
3
+ ALTER TABLE `workspace_trackers` ADD `notion_config` text;
@@ -0,0 +1,14 @@
1
+ -- THE AUTO VERDICT'S TWO RULES + THE NOT-APPROVED REASON (docs/CLAUDE-REVIEW.md § Auto-posting →
2
+ -- AUTO VERDICT). Both workspace columns are OVERRIDES ONLY: NULL = today's rule.
3
+ -- `pair_verdict_strictness`: how a Claude + Codex pair's verdict is folded — NULL / 'strict' = approve
4
+ -- only when both approved; 'relaxed' = approve when one approved and neither asked for changes.
5
+ ALTER TABLE `workspaces` ADD `pair_verdict_strictness` text;
6
+ --> statement-breakpoint
7
+ -- `approve_leftovers`: which open findings may remain when Limn approves — NULL / 'nits_questions' =
8
+ -- nits and questions; 'nits' = nits only; 'none' = nothing open at all.
9
+ ALTER TABLE `workspaces` ADD `approve_leftovers` text;
10
+ --> statement-breakpoint
11
+ -- A REPLIES PASS's not-approved reason ({heldReason, text}), written when the pass ends while the
12
+ -- workspace's auto verdict is on. NULL on every other row (a full review's reason is on its
13
+ -- `auto_post` record, or computed on read).
14
+ ALTER TABLE `claude_reviews` ADD `verdict_reason` text;
@@ -974,6 +974,55 @@
974
974
  "when": 1792137625016,
975
975
  "tag": "0138_team_claim_lines",
976
976
  "breakpoints": true
977
+ },
978
+ {
979
+ "idx": 139,
980
+ "version": "6",
981
+ "when": 1792137626016,
982
+ "tag": "0139_instance_meta",
983
+ "breakpoints": true
984
+ },
985
+ {
986
+ "idx": 140,
987
+ "version": "6",
988
+ "when": 1792137627016,
989
+ "tag": "0140_reference_doc_wiki",
990
+ "breakpoints": true
991
+ },
992
+ {
993
+ "idx": 141,
994
+ "version": "6",
995
+ "when": 1792137629016,
996
+ "tag": "0141_tracker_ticket_comments",
997
+ "breakpoints": true
998
+ },
999
+ {
1000
+ "idx": 142,
1001
+ "version": "6",
1002
+ "when": 1792137630016,
1003
+ "tag": "0142_ticket_review_comments_seen",
1004
+ "breakpoints": true
1005
+ },
1006
+ {
1007
+ "idx": 143,
1008
+ "version": "6",
1009
+ "when": 1792137632016,
1010
+ "tag": "0143_reference_doc_figures",
1011
+ "breakpoints": true
1012
+ },
1013
+ {
1014
+ "idx": 144,
1015
+ "version": "6",
1016
+ "when": 1792137640016,
1017
+ "tag": "0144_workspace_tracker_notion",
1018
+ "breakpoints": true
1019
+ },
1020
+ {
1021
+ "idx": 145,
1022
+ "version": "6",
1023
+ "when": 1792137641016,
1024
+ "tag": "0145_verdict_strictness",
1025
+ "breakpoints": true
977
1026
  }
978
1027
  ]
979
1028
  }
@@ -54,6 +54,7 @@ import { forwardKindOf, isConflicting, isRedCiStatus, RED_CI_STATUSES, } from '.
54
54
  import { authorAutomationFor, dependencyPrState, dependencyStateDetail, MERGE_QUEUE_CARD_DETAIL, securityFixDetail, unionAdvisoryIds, } from './dependency-cards.js';
55
55
  import { deriveSecurityAlerts } from './security-alerts.js';
56
56
  import { pruneTicketReviewsForPrs } from './ticket-review-prune.js';
57
+ import { pruneOrphanTicketComments } from './ticket-comments-prune.js';
57
58
  import { pruneCiReviewsForPrs } from './ci-review-prune.js';
58
59
  import { pruneSequenceDiagramsForPrs } from './sequence-diagram-prune.js';
59
60
  import { failingChecksFields, prFailingChecks, trunkFailingChecks, trunkFailingFor, } from './failing-checks.js';
@@ -7872,6 +7873,8 @@ export async function deleteRepo(id, accountId) {
7872
7873
  // Stored tracker tickets (core since migration 0088 / pg 0075; the plugin used to prune them
7873
7874
  // per PR). No FKs, so nothing would cascade.
7874
7875
  await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
7876
+ // Their tickets' comments (migration 0141), once no PR of this account names the ticket.
7877
+ await pruneOrphanTicketComments(tx, [accountId]);
7875
7878
  // SonarCloud snapshots (migration 0114 / pg 0101). No FKs, so nothing would cascade.
7876
7879
  await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
7877
7880
  // The team-claim ledger (migration 0138). No FKs, so nothing would cascade. This machine's OPEN