limn-review 0.1.166 → 0.1.167

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 (60) hide show
  1. package/dist/api/plugins/rate-limit.js +18 -2
  2. package/dist/api/routes/me.js +48 -2
  3. package/dist/api/routes/repos.js +7 -3
  4. package/dist/auth/account.js +19 -1
  5. package/dist/coding/story-build/docs.js +5 -4
  6. package/dist/db/erase-account.js +9 -0
  7. package/dist/db/export-account.js +20 -0
  8. package/dist/db/feature-gates.js +80 -0
  9. package/dist/db/migrations/0152_reference_doc_chunks.sql +45 -0
  10. package/dist/db/migrations/0153_feature_visibility.sql +16 -0
  11. package/dist/db/migrations/0154_first_landing.sql +15 -0
  12. package/dist/db/migrations/0155_repo_docs_offers.sql +36 -0
  13. package/dist/db/migrations/0156_ci_history_pending.sql +9 -0
  14. package/dist/db/migrations/0157_sprint_source_probe.sql +17 -0
  15. package/dist/db/migrations/0158_repo_docs_unreadable.sql +12 -0
  16. package/dist/db/migrations/meta/_journal.json +49 -0
  17. package/dist/db/my-turn-floor.js +18 -0
  18. package/dist/db/pending-first-landing.js +174 -0
  19. package/dist/db/pending-tabs.js +8 -0
  20. package/dist/db/queries.js +20 -2
  21. package/dist/db/schema.sqlite.js +115 -0
  22. package/dist/index.js +7 -0
  23. package/dist/reference-docs/chat.js +38 -2
  24. package/dist/reference-docs/chunks.js +162 -0
  25. package/dist/reference-docs/manager.js +4 -0
  26. package/dist/reference-docs/prompts.js +7 -1
  27. package/dist/reference-docs/repo-offers-routes.js +129 -0
  28. package/dist/reference-docs/repo-offers-store.js +283 -0
  29. package/dist/reference-docs/repo-offers.js +235 -0
  30. package/dist/reference-docs/retrieve.js +110 -0
  31. package/dist/reference-docs/routes.js +19 -3
  32. package/dist/reference-docs/startup.js +9 -1
  33. package/dist/reference-docs/wiki/github.js +1 -1
  34. package/dist/reference-docs/wiki/routes.js +53 -37
  35. package/dist/review/agentic.js +4 -0
  36. package/dist/review/story-docs/candidates.js +34 -0
  37. package/dist/review/story-docs/manager.js +4 -3
  38. package/dist/review/story-docs/shortlist.js +4 -3
  39. package/dist/shared/features.js +51 -0
  40. package/dist/shared/index.js +2 -0
  41. package/dist/shared/pending-rules.js +13 -0
  42. package/dist/shared/reference-docs-offers.js +63 -0
  43. package/dist/shared/sprint-stories.js +7 -0
  44. package/dist/sync/ci-history-pending.js +49 -0
  45. package/dist/sync/sync-manager.js +678 -297
  46. package/dist/sync/sync-repo.js +3 -2
  47. package/dist/sync/upsert.js +28 -1
  48. package/dist/sync/walk-listeners.js +30 -0
  49. package/dist/tracker/sprint/github.js +290 -9
  50. package/dist/tracker/sprint/probe.js +204 -0
  51. package/dist/tracker/worker.js +5 -0
  52. package/package.json +1 -1
  53. package/public/assets/{AiFixTab-RRZCvJa-.js → AiFixTab-C8YjwWaP.js} +1 -1
  54. package/public/assets/{ClaudeReviewTab-Dorz2I8S.js → ClaudeReviewTab-D0t95V58.js} +1 -1
  55. package/public/assets/{PrBotBehaviourTab-COf3vJXP.js → PrBotBehaviourTab-B-vPLP_q.js} +1 -1
  56. package/public/assets/index-DU917gcx.css +1 -0
  57. package/public/assets/index-DVTxle__.js +89 -0
  58. package/public/index.html +2 -2
  59. package/public/assets/index-BezBYXmy.js +0 -89
  60. package/public/assets/index-CeGG8m25.css +0 -1
@@ -177,6 +177,15 @@ function tierFor(method, path) {
177
177
  // EXACT `===` for the same near-miss.
178
178
  if (path === '/api/me/my-turn-settings')
179
179
  return [TIERS.read];
180
+ // PUT /api/me/feature-settings — Settings → Features (which sidebar destinations show). One
181
+ // validated UPDATE plus DB-only reads; no GitHub, no model. Recorded, EXACT `===`.
182
+ if (path === '/api/me/feature-settings')
183
+ return [TIERS.read];
184
+ // POST /api/me/review-suggestion/dismiss — Pending's one-time AI review card, dismissed for good.
185
+ // One UPDATE of one account column; no GitHub, no model (the card's Run goes through the review
186
+ // start route, which keeps its own tier). Recorded, EXACT `===`.
187
+ if (path === '/api/me/review-suggestion/dismiss')
188
+ return [TIERS.read];
180
189
  // PUT / DELETE /api/my-turn/dismissals/:kind/:id — dismiss or bring back one My Turn entry. One
181
190
  // ownership read and one upsert/delete on a tiny table; no GitHub call, no model call. Recorded
182
191
  // rather than inherited, and spelled with the `/`-terminated prefix: `/api/my-turn` itself (the
@@ -445,9 +454,16 @@ function tierFor(method, path) {
445
454
  // → search + ai + ai_hourly. Spelled EXACTLY here, above the family's `read` line: these GitHub reads
446
455
  // never reach the `hitsGithub` alternation below (that is the GitHub WRITE bucket, and this family
447
456
  // returns first).
448
- if (method === 'POST' && (path === '/api/docs/wiki/preflight' || path === '/api/docs/github/preflight'))
457
+ // The wizard's "From your repositories" (`POST /api/docs/offers/check`, reference-docs/repo-offers-routes.ts)
458
+ // looks at up to DOCS_FROM_REPOS_SCAN_MAX repositories' trees on the reader's GitHub quota (a stored scan
459
+ // is reused) and runs no model → search + read, like the preflights. EXACT `===`.
460
+ if (method === 'POST' &&
461
+ (path === '/api/docs/wiki/preflight' || path === '/api/docs/github/preflight' || path === '/api/docs/offers/check'))
449
462
  return [TIERS.search, TIERS.read];
450
- if (method === 'POST' && path === '/api/docs/github')
463
+ // THE DOCS OFFERS' ADD (reference-docs/repo-offers-routes.ts): each chosen folder is read on GitHub and
464
+ // its build queued, exactly as `POST /api/docs/github` → search + ai + ai_hourly. Its "Not now"
465
+ // (`/api/docs/offers/dismiss`) is DB only and falls to the family's `read` line.
466
+ if (method === 'POST' && (path === '/api/docs/github' || path === '/api/docs/offers/add'))
451
467
  return [TIERS.search, TIERS.ai, TIERS.aiHourly];
452
468
  if (path === '/api/docs' || path.startsWith('/api/docs/'))
453
469
  return [TIERS.read];
@@ -1,6 +1,6 @@
1
- import { MY_TURN_TOGGLES, validateMyTurnSettings } from '../../shared/index.js';
1
+ import { FEATURE_IDS, MY_TURN_TOGGLES, resolveFeatures, validateMyTurnSettings } from '../../shared/index.js';
2
2
  import { config } from '../../config.js';
3
- import { accountToLocalUser, setBlastRadiusConfig, setLargePrCodeLocThreshold, setMyTurnSettings, } from '../../auth/account.js';
3
+ import { accountToLocalUser, setBlastRadiusConfig, setFeatureSettings, setLargePrCodeLocThreshold, setMyTurnSettings, } from '../../auth/account.js';
4
4
  import { resolveLargePrThreshold } from '../../db/code-loc.js';
5
5
  import { exportAccountData } from '../../db/export-account.js';
6
6
  import { accountIdOf } from '../plugins/auth.js';
@@ -17,6 +17,29 @@ import { aiProviderSettingStatus, ALL_RUN_KINDS, CODEX_NOT_AVAILABLE_MESSAGE, CO
17
17
  import { getMyTurn } from '../../db/queries.js';
18
18
  import { clearDailyBriefCountsFor } from '../../db/daily-brief.js';
19
19
  import { dismissMyTurn, restoreMyTurn } from '../../db/my-turn-dismissals.js';
20
+ import { featureFacts } from '../../db/feature-gates.js';
21
+ import { dismissReviewSuggestion } from '../../db/pending-first-landing.js';
22
+ // SETTINGS → FEATURES: the switches as a whole object (a PUT replaces it). Booleans only, the known
23
+ // keys only; `null` resets every switch to its default.
24
+ const featureSettingsSchema = {
25
+ body: {
26
+ type: 'object',
27
+ required: ['settings'],
28
+ additionalProperties: false,
29
+ properties: {
30
+ settings: {
31
+ type: ['object', 'null'],
32
+ additionalProperties: false,
33
+ properties: Object.fromEntries(FEATURE_IDS.map((k) => [k, { type: 'boolean' }])),
34
+ },
35
+ },
36
+ },
37
+ };
38
+ /** Which sidebar destinations show (shared features.ts). DB reads only. */
39
+ async function featuresFor(accountId, account) {
40
+ const facts = await featureFacts(accountId, account?.featuresExistingInstall === true);
41
+ return resolveFeatures(account?.featureSettings ?? null, facts);
42
+ }
20
43
  // The LARGE-PR FLAG's threshold. `null` is a first-class value meaning "reset to the product
21
44
  // default", so the type is the union — NOT an optional key, which would make "clear it" and
22
45
  // "don't change it" the same request. The bounds are validation, not taste: `minimum: 1` because a
@@ -272,6 +295,8 @@ export async function meRoutes(app) {
272
295
  // The MY TURN settings, RAW and nullable (the stored overrides) — the `blastRadius` rule: the
273
296
  // SPA resolves them through the ONE `resolveMyTurnSettings`, so nothing is duplicated here.
274
297
  myTurnSettings: req.account?.myTurnSettings ?? null,
298
+ // SETTINGS → FEATURES, resolved (visibility only; a deep link still opens a hidden one).
299
+ features: await featuresFor(accountId, req.account),
275
300
  // Orgs currently SAML-blocked for this account (empty in the normal case).
276
301
  authNotices: getAuthNotices(accountId),
277
302
  };
@@ -357,6 +382,27 @@ export async function meRoutes(app) {
357
382
  const body = { status: 'ok', myTurnSettings: stored };
358
383
  return body;
359
384
  });
385
+ // SETTINGS → FEATURES — the fourth ONE-PER-ACCOUNT setting: which sidebar destinations show.
386
+ // A PUT of the WHOLE switches object (null resets all); echoes what was STORED and the resolved
387
+ // result, so the sidebar and Settings agree without waiting for /api/me. Visibility only: no
388
+ // sync, read or scoring changes. Rate tier `read` (pinned in rate-limit.test.ts): one validated
389
+ // UPDATE plus the DB-only facts, no GitHub and no model.
390
+ app.put('/api/me/feature-settings', { schema: featureSettingsSchema }, async (req) => {
391
+ const accountId = accountIdOf(req);
392
+ const { settings } = req.body;
393
+ const stored = await setFeatureSettings(accountId, settings);
394
+ const features = await featuresFor(accountId, { ...req.account, featureSettings: stored });
395
+ const body = { status: 'ok', featureSettings: stored, features };
396
+ return body;
397
+ });
398
+ // PENDING'S ONE-TIME AI REVIEW SUGGESTION — dismissed for good (db/pending-first-landing.ts). One
399
+ // UPDATE of one account column, stamped once (a second press keeps the first time). Rate tier
400
+ // `read` (pinned in rate-limit.test.ts): no GitHub, no model.
401
+ app.post('/api/me/review-suggestion/dismiss', async (req) => {
402
+ await dismissReviewSuggestion(accountIdOf(req));
403
+ const body = { status: 'ok' };
404
+ return body;
405
+ });
360
406
  // Access + portability: the whole account as one JSON document (db/export-account.ts). Erasure
361
407
  // is deleting the local database file.
362
408
  app.get('/api/me/export', async (req, reply) => {
@@ -22,6 +22,7 @@ const createRepoSchema = {
22
22
  properties: {
23
23
  owner: { type: 'string', minLength: 1 },
24
24
  name: { type: 'string', minLength: 1 },
25
+ firstRun: { type: 'boolean' },
25
26
  },
26
27
  },
27
28
  };
@@ -316,7 +317,7 @@ export async function repoRoutes(app) {
316
317
  return body;
317
318
  });
318
319
  app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
319
- const { owner, name } = req.body;
320
+ const { owner, name, firstRun } = req.body;
320
321
  const accountId = accountIdOf(req);
321
322
  let resp;
322
323
  try {
@@ -350,7 +351,10 @@ export async function repoRoutes(app) {
350
351
  }
351
352
  const canonOwner = resp.repository.owner.login;
352
353
  const canonName = resp.repository.name;
353
- const repoId = await upsertRepo(canonOwner, canonName, resp.repository.id, null, accountId);
354
+ const repoId = await upsertRepo(canonOwner, canonName, resp.repository.id, null, accountId, undefined, undefined,
355
+ // The reader's add: decides the first-onboarding mark (My Turn's 7-day lookback, Pending's
356
+ // summary window) inside the same transaction as the insert.
357
+ { firstRun: firstRun === true });
354
358
  // No second visibility axis to set: `upsertRepo` gives the repo its workspace membership
355
359
  // in the same transaction, and a repo in a workspace is fully live — Feed, Activity, My
356
360
  // Turn and Bots all cover it. (The old `setRepoInboxWatch(accountId, repoId, true)` call
@@ -385,7 +389,7 @@ export async function repoRoutes(app) {
385
389
  // when its turn came (the walk's upsertRepo re-creates the row). Cancelling a queued
386
390
  // repo is synchronous: it dequeues + clears the progress row, so the delete can go.
387
391
  if (isSyncQueued(id))
388
- requestSyncCancel(id);
392
+ requestSyncCancel(id, { dropFollowOns: true });
389
393
  // A sync in flight would re-create the repo (and its rows) right after we
390
394
  // delete them, since the sync's upserts are still running. Refuse until it
391
395
  // settles — the cron tick / initial backfill is short.
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { eq } from 'drizzle-orm';
3
- import { compactMyTurnSettings } from '../shared/index.js';
3
+ import { compactFeatureSettings, compactMyTurnSettings } from '../shared/index.js';
4
4
  import { db, schema } from '../db/client.js';
5
5
  import { getGithubTokenAsync } from '../github/auth.js';
6
6
  const STALE_MS = 24 * 60 * 60 * 1000; // re-fetch the local identity once a day
@@ -35,6 +35,8 @@ function rowToAccount(row) {
35
35
  largePrCodeLocThreshold: row.largePrCodeLocThreshold ?? null,
36
36
  blastRadiusConfig: row.blastRadiusConfig ?? null,
37
37
  myTurnSettings: row.myTurnSettings ?? null,
38
+ featureSettings: row.featureSettings ?? null,
39
+ featuresExistingInstall: row.featuresExistingInstall,
38
40
  };
39
41
  }
40
42
  // Module cache of the local account, set by ensureLocalAccount() at startup so
@@ -294,6 +296,22 @@ export async function setMyTurnSettings(accountId, settings) {
294
296
  await refreshLocalAccountCache(accountId);
295
297
  return value;
296
298
  }
299
+ /**
300
+ * SETTINGS → FEATURES: store the reader's switches (overrides only, compacted; null resets every
301
+ * switch to its default). Returns what was stored.
302
+ */
303
+ export async function setFeatureSettings(accountId, settings) {
304
+ const { accounts } = schema;
305
+ const value = compactFeatureSettings(settings);
306
+ await db
307
+ .update(accounts)
308
+ .set({ featureSettings: value })
309
+ .where(eq(accounts.id, accountId))
310
+ .execute();
311
+ // ⚠ The settings rule above: without the refresh, /api/me would hand the old value back.
312
+ await refreshLocalAccountCache(accountId);
313
+ return value;
314
+ }
297
315
  /**
298
316
  * Resolve an account's owner to a row in `users` (by login) if they've appeared
299
317
  * in any synced repo. Returns null when "you" haven't authored/acted anywhere
@@ -13,14 +13,14 @@ import { pickReviewNonce } from '../../review/claude-review/prompts.js';
13
13
  import { buildBuildDocsPrompt, buildDocsAbouts, buildDocsSystemPrompt, buildDocsUntrustedTexts, } from '../../review/story-docs/build-prompts.js';
14
14
  import { validateBuildDocs } from '../../review/story-docs/build-validate.js';
15
15
  import { inlineSections, storyDocsRoots, storyDocsTurnCap } from '../../review/story-docs/prompts.js';
16
- import { prefilterSections, runShortlist, wholeLibraryShortlist } from '../../review/story-docs/shortlist.js';
16
+ import { runShortlist, wholeLibraryShortlist } from '../../review/story-docs/shortlist.js';
17
17
  import { storyBuildLimits } from './limits.js';
18
18
  import { getStoryBuildRow, listStoryBuildRepoRows, updateStoryBuild } from './persist.js';
19
19
  // DOCS CONSIDERATION — WORK PACKAGE 3 (docs/STORY-BUILD.md § Docs consideration). THE STORY WINS.
20
20
  //
21
21
  // runBuildDocs the story-docs pipeline's `build` mode over ONE session: the frozen story (seed), the
22
22
  // gap fold and the CURRENT plan revision → the shortlist (the whole library when it fits,
23
- // else the BM25 prefilter → Haiku) → ONE low-effort read over the inlined sections,
23
+ // else full-text search → Haiku) → ONE low-effort read over the inlined sections,
24
24
  // answering `submit_build_docs` (grounded / superseded / suggestion) → every reference
25
25
  // checked against the shortlisted documents (an unreferenced item DROPPED) → stored on the
26
26
  // SESSION's row (`docs_json`; never `story_docs_alignments`, so the per-story check's
@@ -137,11 +137,12 @@ export const runBuildDocs = async (ctx, { accountId, buildId, force, onProgress,
137
137
  const budgetLeft = storyBuildBudgetLeftUsd(row, await listStoryBuildRepoRows(ctx, accountId, buildId));
138
138
  if (budgetLeft < STORY_BUILD_MIN_BUDGET_LEFT_USD)
139
139
  return finish({ docsStatus: 'failed', docsError: BUILD_DOCS_TEXT.budget });
140
- const [{ loadLibrarySnapshot }, { readIndex }, { libraryDir }, { shortlistDocsOf }] = await Promise.all([
140
+ const [{ loadLibrarySnapshot }, { readIndex }, { libraryDir }, { shortlistDocsOf }, { storyDocsCandidates }] = await Promise.all([
141
141
  import('../../reference-docs/context.js'),
142
142
  import('../../reference-docs/library.js'),
143
143
  import('../../reference-docs/paths.js'),
144
144
  import('../../review/story-docs/manager.js'),
145
+ import('../../review/story-docs/candidates.js'),
145
146
  ]);
146
147
  const [index, lib] = await Promise.all([readIndex(accountId, row.workspaceId), loadLibrarySnapshot(accountId, row.workspaceId)]);
147
148
  // 1. THE SHORTLIST — every section when the library fits, else the prefilter's candidates → Haiku.
@@ -163,7 +164,7 @@ export const runBuildDocs = async (ctx, { accountId, buildId, force, onProgress,
163
164
  criteria: queryCriteria,
164
165
  index,
165
166
  docs,
166
- candidates: prefilterSections({ story: storyText, criteria: queryCriteria }, docs),
167
+ candidates: await storyDocsCandidates({ accountId, workspaceId: row.workspaceId }, { story: storyText, criteria: queryCriteria }, docs),
167
168
  },
168
169
  lib,
169
170
  ...(seams.complete ? { complete: seams.complete } : {}),
@@ -189,9 +189,14 @@ export async function eraseAccountData(accountId) {
189
189
  // Wiki sources (migration 0140): the page rows (before their documents — composite FK) and the
190
190
  // sealed wiki credential. Both cascade too, and are explicit anyway.
191
191
  await tx.delete(schema.referenceDocWikiPages).where(eq(schema.referenceDocWikiPages.accountId, accountId)).execute();
192
+ // Full-text passages (migration 0152): derived from the files, before their documents (composite FK).
193
+ // The FTS5 index follows by trigger. They cascade too, and are explicit anyway.
194
+ await tx.delete(schema.referenceDocChunks).where(eq(schema.referenceDocChunks.accountId, accountId)).execute();
192
195
  await tx.delete(schema.workspaceWikiCredentials).where(eq(schema.workspaceWikiCredentials.accountId, accountId)).execute();
193
196
  // Figure descriptions (migration 0143): the account's image-description cache.
194
197
  await tx.delete(schema.referenceDocFigureDescriptions).where(eq(schema.referenceDocFigureDescriptions.accountId, accountId)).execute();
198
+ // Docs offers (migration 0155): cascade from workspaces and repos too, and are explicit anyway.
199
+ await tx.delete(schema.referenceDocRepoOffers).where(eq(schema.referenceDocRepoOffers.accountId, accountId)).execute();
195
200
  await tx.delete(schema.referenceDocs).where(eq(schema.referenceDocs.accountId, accountId)).execute();
196
201
  // Story docs checks (migration 0121 / pg 0108): cascade from workspaces too, and are explicit anyway.
197
202
  await tx.delete(schema.storyDocsAlignments).where(eq(schema.storyDocsAlignments.accountId, accountId)).execute();
@@ -375,6 +380,10 @@ export function accountScopedTables() {
375
380
  },
376
381
  // Wiki sources (migration 0140), erased explicitly above; `workspaceWikiCredentials` holds a credential.
377
382
  { name: 'referenceDocWikiPages', col: schema.referenceDocWikiPages.accountId, table: schema.referenceDocWikiPages },
383
+ // Full-text passages (migration 0152), erased explicitly above. Derived, so not exported.
384
+ { name: 'referenceDocChunks', col: schema.referenceDocChunks.accountId, table: schema.referenceDocChunks },
385
+ // Docs offers (migration 0155), erased explicitly above.
386
+ { name: 'referenceDocRepoOffers', col: schema.referenceDocRepoOffers.accountId, table: schema.referenceDocRepoOffers },
378
387
  // Figure descriptions (migration 0143), erased explicitly above.
379
388
  {
380
389
  name: 'referenceDocFigureDescriptions',
@@ -53,6 +53,9 @@ export async function exportAccountData(accountId) {
53
53
  largePrCodeLocThreshold: accounts.largePrCodeLocThreshold,
54
54
  blastRadiusConfig: accounts.blastRadiusConfig,
55
55
  myTurnSettings: accounts.myTurnSettings,
56
+ featureSettings: accounts.featureSettings,
57
+ featuresExistingInstall: accounts.featuresExistingInstall,
58
+ reviewSuggestionDismissedAt: accounts.reviewSuggestionDismissedAt,
56
59
  aiBudgetSettings: accounts.aiBudgetSettings,
57
60
  aiBudgetReading: accounts.aiBudgetReading,
58
61
  lastLoginAt: accounts.lastLoginAt,
@@ -162,6 +165,22 @@ export async function exportAccountData(accountId) {
162
165
  .from(schema.referenceDocFigureDescriptions)
163
166
  .where(eq(schema.referenceDocFigureDescriptions.accountId, accountId))
164
167
  .execute();
168
+ const repoOfferRows = await db
169
+ .select({
170
+ workspaceId: schema.referenceDocRepoOffers.workspaceId,
171
+ repoId: schema.referenceDocRepoOffers.repoId,
172
+ path: schema.referenceDocRepoOffers.path,
173
+ docCount: schema.referenceDocRepoOffers.docCount,
174
+ wordEstimate: schema.referenceDocRepoOffers.wordEstimate,
175
+ tooLarge: schema.referenceDocRepoOffers.tooLarge,
176
+ state: schema.referenceDocRepoOffers.state,
177
+ docId: schema.referenceDocRepoOffers.docId,
178
+ detectedAt: schema.referenceDocRepoOffers.detectedAt,
179
+ decidedAt: schema.referenceDocRepoOffers.decidedAt,
180
+ })
181
+ .from(schema.referenceDocRepoOffers)
182
+ .where(eq(schema.referenceDocRepoOffers.accountId, accountId))
183
+ .execute();
165
184
  const docChatRows = await db
166
185
  .select({
167
186
  workspaceId: schema.referenceDocChatMessages.workspaceId,
@@ -386,6 +405,7 @@ export async function exportAccountData(accountId) {
386
405
  // ⚠ PRESENCE ONLY — never the stored token, in any form.
387
406
  workspaceWikiCredentials: wikiCredentialRows.map((t) => ({ ...t, hasToken: t.hasToken != null && t.hasToken !== '' })),
388
407
  referenceDocFigureDescriptions: figureRows,
408
+ referenceDocRepoOffers: repoOfferRows,
389
409
  repoRelations: repoRelationRows,
390
410
  sprintCodeJudgements: capped('sprintCodeJudgements', codeJudgementRows),
391
411
  referenceDocChatMessages: capped('referenceDocChatMessages', docChatRows),
@@ -0,0 +1,80 @@
1
+ import { and, eq, inArray, isNotNull } from 'drizzle-orm';
2
+ import { db, schema } from './client.js';
3
+ // SETTINGS → FEATURES — the facts behind the defaults (shared features.ts `resolveFeatures`). Read on
4
+ // every GET /api/me, so each is ONE small indexed DB read: no tracker call, no GitHub call. Every
5
+ // query predicates on accountId.
6
+ /** At least one of the account's repositories has finished its 90-day backfill (docs/SYNC.md: a
7
+ * repo stays "never fully synced" until phase 2 ends, so `last_full_sync_at` is that moment). */
8
+ export async function hasCompletedBackfill(accountId) {
9
+ const { repos, syncState } = schema;
10
+ const rows = await db
11
+ .select({ id: repos.id })
12
+ .from(syncState)
13
+ .innerJoin(repos, eq(repos.id, syncState.repoId))
14
+ .where(and(eq(repos.accountId, accountId), isNotNull(syncState.lastFullSyncAt)))
15
+ .limit(1)
16
+ .execute();
17
+ return rows.length > 0;
18
+ }
19
+ /** Is this stored sprint-read source a real sprint (not the status fallback)? */
20
+ export function isSprintSource(source) {
21
+ if (source == null)
22
+ return false;
23
+ switch (source.kind) {
24
+ case 'sprint':
25
+ case 'cycle':
26
+ case 'iteration':
27
+ return true;
28
+ case 'milestone':
29
+ // Only a milestone with a due date makes a sprint.
30
+ return source.dueOn != null;
31
+ case 'status_fallback':
32
+ return false;
33
+ }
34
+ }
35
+ /** The trackers whose configuration names sprints or cycles (GitHub Issues is the automatic default
36
+ * in most workspaces, so its configuration alone says nothing). */
37
+ const SPRINT_TRACKERS = ['jira', 'linear', 'notion'];
38
+ /**
39
+ * Does any workspace of this account have a sprint source? True when:
40
+ * - the workspace's last sprint read found a sprint, a cycle, a GitHub Projects iteration (the
41
+ * current one, docs/TRACKERS.md § GitHub Projects iterations) or a milestone with a due date; or
42
+ * - the workspace has no successful read yet and the tracker worker's SPRINT-SOURCE PROBE (GitHub
43
+ * Issues workspaces only, tracker/sprint/probe.ts — never run by a view) found a current
44
+ * iteration or a dated milestone; or
45
+ * - the workspace has a Jira, Linear or Notion tracker configured and no read has said otherwise
46
+ * yet (the read runs once This Sprint is opened; until then, a configured tracker counts).
47
+ *
48
+ * ⚠ THE EXTENSION POINT for a further source: add it to `isSprintSource`, and the Features default
49
+ * follows.
50
+ */
51
+ export async function hasSprintSource(accountId) {
52
+ const sr = schema.sprintReads;
53
+ const reads = await db
54
+ .select({ workspaceId: sr.workspaceId, source: sr.sourceJson, readAt: sr.readAt, probe: sr.probeSourceJson })
55
+ .from(sr)
56
+ .where(eq(sr.accountId, accountId))
57
+ .execute();
58
+ if (reads.some((r) => isSprintSource(r.source)))
59
+ return true;
60
+ // The probe's answer counts only until a read exists: a read's own source is the answer then.
61
+ if (reads.some((r) => r.readAt == null && isSprintSource(r.probe)))
62
+ return true;
63
+ const readWorkspaces = new Set(reads.filter((r) => r.readAt != null && r.source != null).map((r) => r.workspaceId));
64
+ const wt = schema.workspaceTrackers;
65
+ const trackers = await db
66
+ .select({ workspaceId: wt.workspaceId })
67
+ .from(wt)
68
+ .where(and(eq(wt.accountId, accountId), inArray(wt.provider, [...SPRINT_TRACKERS])))
69
+ .execute();
70
+ return trackers.some((t) => !readWorkspaces.has(t.workspaceId));
71
+ }
72
+ /** Everything `resolveFeatures` needs, for one account. */
73
+ export async function featureFacts(accountId, existingInstall) {
74
+ // An existing install shows everything whatever the facts say: skip the reads.
75
+ if (existingInstall)
76
+ return { existingInstall, reportsReady: true, sprintSource: true };
77
+ const [reportsReady, sprintSource] = await Promise.all([hasCompletedBackfill(accountId), hasSprintSource(accountId)]);
78
+ return { existingInstall, reportsReady, sprintSource };
79
+ }
80
+ //# sourceMappingURL=feature-gates.js.map
@@ -0,0 +1,45 @@
1
+ -- FULL-TEXT SEARCH OVER THE REFERENCE LIBRARY (2026-11-01; docs/DOCS-RAG.md, docs/REFERENCE-DOCS.md
2
+ -- § Retrieval). CORE, local only. Additive.
3
+ --
4
+ -- reference_doc_chunks NEW: a built document's section text cut into passages of ≤ 220 words,
5
+ -- each prefixed with "Document › trail › section" and the section's
6
+ -- one-line summary. Rewritten whole by every build (reference-docs/chunks.ts);
7
+ -- DERIVED from the files on disk, so a missing row is rebuilt at boot.
8
+ -- Composite FKs onto the document and the workspace; cascades.
9
+ -- reference_doc_chunks_fts NEW FTS5 index over the chunks' text (external content, porter + unicode61).
10
+ -- ⚠ Drizzle cannot declare a virtual table or a trigger: they live HERE only.
11
+ -- The three triggers keep it in step on every insert, update and delete —
12
+ -- including the FK cascades from a deleted document, workspace or account.
13
+ CREATE TABLE `reference_doc_chunks` (
14
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
15
+ `account_id` integer NOT NULL,
16
+ `workspace_id` integer NOT NULL,
17
+ `doc_id` integer NOT NULL,
18
+ `built_at` integer NOT NULL,
19
+ `section_path` text NOT NULL,
20
+ `anchor` text,
21
+ `ordinal` integer NOT NULL,
22
+ `text` text NOT NULL,
23
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
24
+ CONSTRAINT `reference_doc_chunks_doc_account_fk` FOREIGN KEY (`doc_id`,`account_id`) REFERENCES `reference_docs`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade,
25
+ CONSTRAINT `reference_doc_chunks_workspace_account_fk` FOREIGN KEY (`workspace_id`,`account_id`) REFERENCES `workspaces`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
26
+ );
27
+ --> statement-breakpoint
28
+ CREATE INDEX `reference_doc_chunks_scope` ON `reference_doc_chunks` (`account_id`,`workspace_id`,`doc_id`);
29
+ --> statement-breakpoint
30
+ CREATE INDEX `reference_doc_chunks_doc` ON `reference_doc_chunks` (`doc_id`,`account_id`);
31
+ --> statement-breakpoint
32
+ CREATE VIRTUAL TABLE `reference_doc_chunks_fts` USING fts5(`text`, content='reference_doc_chunks', content_rowid='id', tokenize='porter unicode61');
33
+ --> statement-breakpoint
34
+ CREATE TRIGGER `reference_doc_chunks_fts_ai` AFTER INSERT ON `reference_doc_chunks` BEGIN
35
+ INSERT INTO `reference_doc_chunks_fts`(rowid, `text`) VALUES (new.`id`, new.`text`);
36
+ END;
37
+ --> statement-breakpoint
38
+ CREATE TRIGGER `reference_doc_chunks_fts_ad` AFTER DELETE ON `reference_doc_chunks` BEGIN
39
+ INSERT INTO `reference_doc_chunks_fts`(`reference_doc_chunks_fts`, rowid, `text`) VALUES ('delete', old.`id`, old.`text`);
40
+ END;
41
+ --> statement-breakpoint
42
+ CREATE TRIGGER `reference_doc_chunks_fts_au` AFTER UPDATE ON `reference_doc_chunks` BEGIN
43
+ INSERT INTO `reference_doc_chunks_fts`(`reference_doc_chunks_fts`, rowid, `text`) VALUES ('delete', old.`id`, old.`text`);
44
+ INSERT INTO `reference_doc_chunks_fts`(rowid, `text`) VALUES (new.`id`, new.`text`);
45
+ END;
@@ -0,0 +1,16 @@
1
+ -- SETTINGS → FEATURES (2026-11-01; docs/FRONTEND.md § Features shown, shared features.ts). CORE,
2
+ -- additive, visibility only.
3
+ --
4
+ -- accounts.feature_settings NEW: the reader's switches, overrides only (JSON). NULL = every
5
+ -- default applies. Written only by PUT /api/me/feature-settings.
6
+ -- accounts.features_existing_install NEW: 1 for an account that already had repositories when this
7
+ -- migration ran — its defaults show every destination, so an
8
+ -- existing install looks exactly as before. A new install
9
+ -- (an account created later, or one with no repos yet) is 0 and
10
+ -- starts with Bots Monitoring and Feed hidden.
11
+ ALTER TABLE `accounts` ADD `feature_settings` text;
12
+ --> statement-breakpoint
13
+ ALTER TABLE `accounts` ADD `features_existing_install` integer DEFAULT 0 NOT NULL;
14
+ --> statement-breakpoint
15
+ UPDATE `accounts` SET `features_existing_install` = 1
16
+ WHERE EXISTS (SELECT 1 FROM `repos` WHERE `repos`.`account_id` = `accounts`.`id`);
@@ -0,0 +1,15 @@
1
+ -- PENDING ON THE FIRST LANDING (2026-11-01; docs/BACKEND.md § My Turn — The first-onboarding
2
+ -- lookback, docs/FRONTEND.md § Pending on the first landing). CORE, additive, no backfill.
3
+ --
4
+ -- repos.first_onboarding NEW: 1 for a repo added while the account had no repos,
5
+ -- or in that same first-run batch. Its My Turn floor is
6
+ -- its add time minus FIRST_ONBOARDING_LOOKBACK_DAYS; a
7
+ -- repo added later keeps its add time. Written once, at
8
+ -- insert (sync/upsert.ts upsertRepo), never updated.
9
+ -- accounts.review_suggestion_dismissed_at NEW: when the reader dismissed Pending's one-time
10
+ -- "Review your PR with Claude" card. NULL = never.
11
+ --
12
+ -- Existing repos stay 0: an install that already has repos is past its first landing.
13
+ ALTER TABLE `repos` ADD `first_onboarding` integer DEFAULT 0 NOT NULL;
14
+ --> statement-breakpoint
15
+ ALTER TABLE `accounts` ADD `review_suggestion_dismissed_at` integer;
@@ -0,0 +1,36 @@
1
+ -- DOCS OFFERED FROM THE REPOSITORIES (2026-11-01; docs/REFERENCE-DOCS.md § Docs offered from your
2
+ -- repositories). CORE, additive, no backfill.
3
+ --
4
+ -- reference_doc_repo_offers NEW: one row per (workspace, repository, folder) found to look like a docs
5
+ -- collection. `state` = 'offered' (waiting for the reader), 'declined'
6
+ -- ("Not now": never offered again) or 'added' (a document was queued;
7
+ -- `doc_id` names it, kept after the document is removed so the folder is
8
+ -- not offered again). Nothing is built from a row until the reader presses
9
+ -- Add. Composite FKs onto the workspace and the repository; both cascade,
10
+ -- and repos' delete clears it explicitly too.
11
+ -- repos.docs_offer_scanned_at NEW: when the repository's tree was last looked at for docs folders.
12
+ -- NULL = never; looked at again after DOCS_OFFER_RESCAN_DAYS.
13
+ CREATE TABLE `reference_doc_repo_offers` (
14
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
15
+ `account_id` integer NOT NULL,
16
+ `workspace_id` integer NOT NULL,
17
+ `repo_id` integer NOT NULL,
18
+ `path` text NOT NULL,
19
+ `doc_count` integer NOT NULL,
20
+ `word_estimate` integer NOT NULL,
21
+ `page_count` integer NOT NULL,
22
+ `too_large` integer DEFAULT 0 NOT NULL,
23
+ `state` text DEFAULT 'offered' NOT NULL,
24
+ `doc_id` integer,
25
+ `detected_at` integer NOT NULL,
26
+ `decided_at` integer,
27
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
28
+ CONSTRAINT `reference_doc_repo_offers_workspace_account_fk` FOREIGN KEY (`workspace_id`,`account_id`) REFERENCES `workspaces`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade,
29
+ CONSTRAINT `reference_doc_repo_offers_repo_account_fk` FOREIGN KEY (`repo_id`,`account_id`) REFERENCES `repos`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
30
+ );
31
+ --> statement-breakpoint
32
+ CREATE UNIQUE INDEX `reference_doc_repo_offers_folder` ON `reference_doc_repo_offers` (`account_id`,`workspace_id`,`repo_id`,`path`);
33
+ --> statement-breakpoint
34
+ CREATE INDEX `reference_doc_repo_offers_scope` ON `reference_doc_repo_offers` (`account_id`,`workspace_id`,`state`);
35
+ --> statement-breakpoint
36
+ ALTER TABLE `repos` ADD `docs_offer_scanned_at` integer;
@@ -0,0 +1,9 @@
1
+ -- THE CI-HISTORY "STILL OWED" STAMP (2026-11-02; docs/SYNC.md § The CI-history backfill is owed
2
+ -- until it ran). CORE, additive, no backfill.
3
+ --
4
+ -- sync_state.ci_history_pending_at NEW: set when a completed FULL walk owes the one-time
5
+ -- CI-history backfill (before it is run or parked in the
6
+ -- in-memory queue); cleared once the backfill ran. A
7
+ -- non-NULL value at boot, or at the repo's next walk, runs
8
+ -- it then. NULL = nothing owed.
9
+ ALTER TABLE `sync_state` ADD `ci_history_pending_at` integer;
@@ -0,0 +1,17 @@
1
+ -- THE SPRINT-SOURCE PROBE (2026-11-02; docs/TRACKERS.md § The sprint-source probe). CORE, additive, no
2
+ -- backfill.
3
+ --
4
+ -- sprint_reads.probe_checked_at NEW: when the tracker worker last asked GitHub whether this GitHub
5
+ -- Issues workspace has a current Projects iteration or an open
6
+ -- milestone with a due date. NULL = never. Asked again after
7
+ -- SPRINT_PROBE_INTERVAL_MS, and never once a sprint read exists.
8
+ -- sprint_reads.probe_source_json NEW: what the probe found (a `SprintSource`, kind 'iteration' or
9
+ -- 'milestone'), NULL = nothing. Read ONLY by `hasSprintSource` (the
10
+ -- This Sprint default in Settings → Features), and only while the
11
+ -- workspace has no successful sprint read — a read's own source wins.
12
+ --
13
+ -- A probe may CREATE the workspace's row (`viewed_at` = epoch 0, `next_read_at` = epoch 0): the demand
14
+ -- gate is unchanged — a row whose tab was never opened is never read.
15
+ ALTER TABLE `sprint_reads` ADD `probe_checked_at` integer;
16
+ --> statement-breakpoint
17
+ ALTER TABLE `sprint_reads` ADD `probe_source_json` text;
@@ -0,0 +1,12 @@
1
+ -- A REPOSITORY WHOSE FILES CAN'T BE READ (2026-11-02; docs/REFERENCE-DOCS.md § A repository whose files
2
+ -- can't be read). CORE, additive, no backfill.
3
+ --
4
+ -- repos.docs_offer_failed_at NEW: when the docs-offer look at the repository's tree last FAILED. NULL =
5
+ -- the last look succeeded (or none failed). While set, the repository is not
6
+ -- looked at again until DOCS_OFFER_FAILURE_RETRY_HOURS have passed (then at
7
+ -- most once a day). Cleared by the next look that succeeds.
8
+ -- repos.docs_offer_failure NEW: why, as a code: 'no_access' | 'empty' | 'not_found' | 'unavailable'.
9
+ -- Set and cleared with `docs_offer_failed_at`. Never touches the PR sync.
10
+ ALTER TABLE `repos` ADD `docs_offer_failed_at` integer;
11
+ --> statement-breakpoint
12
+ ALTER TABLE `repos` ADD `docs_offer_failure` text;
@@ -1065,6 +1065,55 @@
1065
1065
  "when": 1792224054016,
1066
1066
  "tag": "0151_limn_finding_bots",
1067
1067
  "breakpoints": true
1068
+ },
1069
+ {
1070
+ "idx": 152,
1071
+ "version": "6",
1072
+ "when": 1792224055016,
1073
+ "tag": "0152_reference_doc_chunks",
1074
+ "breakpoints": true
1075
+ },
1076
+ {
1077
+ "idx": 153,
1078
+ "version": "6",
1079
+ "when": 1792224056016,
1080
+ "tag": "0153_feature_visibility",
1081
+ "breakpoints": true
1082
+ },
1083
+ {
1084
+ "idx": 154,
1085
+ "version": "6",
1086
+ "when": 1792224057016,
1087
+ "tag": "0154_first_landing",
1088
+ "breakpoints": true
1089
+ },
1090
+ {
1091
+ "idx": 155,
1092
+ "version": "6",
1093
+ "when": 1792224058016,
1094
+ "tag": "0155_repo_docs_offers",
1095
+ "breakpoints": true
1096
+ },
1097
+ {
1098
+ "idx": 156,
1099
+ "version": "6",
1100
+ "when": 1792224059016,
1101
+ "tag": "0156_ci_history_pending",
1102
+ "breakpoints": true
1103
+ },
1104
+ {
1105
+ "idx": 157,
1106
+ "version": "6",
1107
+ "when": 1792224060016,
1108
+ "tag": "0157_sprint_source_probe",
1109
+ "breakpoints": true
1110
+ },
1111
+ {
1112
+ "idx": 158,
1113
+ "version": "6",
1114
+ "when": 1792224061016,
1115
+ "tag": "0158_repo_docs_unreadable",
1116
+ "breakpoints": true
1068
1117
  }
1069
1118
  ]
1070
1119
  }
@@ -0,0 +1,18 @@
1
+ // ── MY TURN'S ONBOARDING FLOOR (docs/BACKEND.md § My Turn — The first-onboarding lookback) ──────
2
+ //
3
+ // An event before a repo's floor never summons anyone: without it, adding a repo and its 90-day
4
+ // backfill would surface two months of old replies, mentions and pushes at once. The floor is the
5
+ // repo's add time (`repos.createdAt`) — except for a repo of the account's FIRST onboarding
6
+ // (`repos.first_onboarding`), whose floor sits `FIRST_ONBOARDING_LOOKBACK_DAYS` earlier, so the
7
+ // first landing on Pending shows the last week's open business instead of nothing.
8
+ //
9
+ // ⚠ ONE spelling: `getMyTurn` builds its per-repo floor map through this and every rule that reads
10
+ // the floor (S2, S3c, S3d, S5, S6) reads that map. Never re-derive it from `createdAt` elsewhere.
11
+ import { FIRST_ONBOARDING_LOOKBACK_DAYS } from '../shared/index.js';
12
+ const DAY_MS = 86_400_000;
13
+ export function myTurnFloorOf(repo) {
14
+ if (!repo.firstOnboarding)
15
+ return repo.createdAt;
16
+ return new Date(repo.createdAt.getTime() - FIRST_ONBOARDING_LOOKBACK_DAYS * DAY_MS);
17
+ }
18
+ //# sourceMappingURL=my-turn-floor.js.map