akm-cli 0.9.0-beta.9 → 0.9.0-rc.0

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 (325) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -21
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +156 -155
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +3 -3
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1091
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1295 -1277
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +228 -605
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +54 -3
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +157 -10
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +28 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +485 -2764
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +37 -35
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +206 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +2 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -895
  138. package/dist/commands/read/curate.js +410 -111
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +19 -39
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +382 -62
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +18 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +132 -1126
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +259 -769
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +36 -92
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +18 -11
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +182 -204
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +34 -11
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2661 -2369
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +883 -596
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/website.js +9 -5
  286. package/dist/sources/website-ingest.js +187 -29
  287. package/dist/sources/wiki-fetchers/registry.js +53 -0
  288. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  289. package/dist/storage/database.js +45 -10
  290. package/dist/storage/managed-db.js +82 -0
  291. package/dist/storage/repositories/canaries-repository.js +107 -0
  292. package/dist/storage/repositories/consolidation-repository.js +38 -0
  293. package/dist/storage/repositories/embeddings-repository.js +72 -0
  294. package/dist/storage/repositories/events-repository.js +187 -0
  295. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  296. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  297. package/dist/storage/repositories/index-db.js +14 -8
  298. package/dist/storage/repositories/proposals-repository.js +220 -0
  299. package/dist/storage/repositories/recombine-repository.js +213 -0
  300. package/dist/storage/repositories/registry-cache.js +93 -0
  301. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  302. package/dist/storage/repositories/task-history-repository.js +93 -0
  303. package/dist/storage/sqlite-pragmas.js +146 -0
  304. package/dist/tasks/backends/cron.js +1 -1
  305. package/dist/tasks/backends/index.js +9 -0
  306. package/dist/tasks/backends/launchd.js +1 -1
  307. package/dist/tasks/backends/schtasks.js +1 -1
  308. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  309. package/dist/tasks/runner.js +15 -13
  310. package/dist/text-import-hook.mjs +0 -0
  311. package/dist/wiki/wiki.js +52 -11
  312. package/dist/workflows/cli.js +1 -0
  313. package/dist/workflows/db.js +3 -4
  314. package/dist/workflows/runtime/runs.js +43 -118
  315. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  316. package/dist/workflows/validate-summary.js +2 -7
  317. package/docs/README.md +69 -18
  318. package/docs/data-and-telemetry.md +5 -4
  319. package/docs/migration/release-notes/0.7.0.md +1 -1
  320. package/docs/migration/release-notes/0.9.0.md +39 -0
  321. package/package.json +10 -10
  322. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  323. package/dist/commands/db-cli.js +0 -23
  324. package/dist/indexer/db/db-backup.js +0 -376
  325. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -0,0 +1,187 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { error } from "../../core/warn.js";
5
+ /**
6
+ * Convert a raw `EventRow` from the database to the public `EventEnvelope`
7
+ * interface used throughout the events module.
8
+ */
9
+ export function eventRowToEnvelope(row) {
10
+ let metadata;
11
+ try {
12
+ const parsed = JSON.parse(row.metadata_json);
13
+ // Only attach metadata when the JSON blob is non-empty so downstream
14
+ // consumers that check `envelope.metadata !== undefined` keep working.
15
+ if (Object.keys(parsed).length > 0) {
16
+ metadata = parsed;
17
+ }
18
+ }
19
+ catch {
20
+ // Corrupt JSON in the DB — treat as no metadata.
21
+ }
22
+ return {
23
+ schemaVersion: 1,
24
+ id: row.id,
25
+ ts: row.ts,
26
+ eventType: row.event_type,
27
+ ...(row.ref !== null ? { ref: row.ref } : {}),
28
+ ...(metadata !== undefined ? { metadata } : {}),
29
+ };
30
+ }
31
+ /**
32
+ * Insert a single event. Returns the auto-assigned monotonic rowid, which
33
+ * callers can store as a "sinceId" cursor for future `readEventsSince` calls.
34
+ *
35
+ * Best-effort: mirrors the behaviour of the old `appendEvent` — errors are
36
+ * caught and logged to stderr rather than propagated so observability never
37
+ * breaks mutation.
38
+ */
39
+ export function insertEvent(db, input) {
40
+ try {
41
+ const result = db
42
+ .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
43
+ VALUES (?, ?, ?, ?)
44
+ RETURNING id`)
45
+ .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {}));
46
+ return result?.id;
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ error(`akm: state.db event insert failed (${message})`);
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Read events from the database matching the filter. Returns events in
56
+ * ascending id order so consumers can process them in emission order.
57
+ *
58
+ * The returned `nextId` is the maximum id seen (or `sinceId` when no rows
59
+ * match), suitable as the next `sinceId` cursor value.
60
+ */
61
+ export function readStateEvents(db, options = {}) {
62
+ const conditions = [];
63
+ const params = [];
64
+ if (options.sinceId !== undefined && options.sinceId > 0) {
65
+ conditions.push("id > ?");
66
+ params.push(options.sinceId);
67
+ }
68
+ if (options.since) {
69
+ conditions.push("ts >= ?");
70
+ params.push(options.since);
71
+ }
72
+ if (options.type) {
73
+ conditions.push("event_type = ?");
74
+ params.push(options.type);
75
+ }
76
+ if (options.ref) {
77
+ conditions.push("ref = ?");
78
+ params.push(options.ref);
79
+ }
80
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
81
+ const rows = db
82
+ .prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ORDER BY id ASC`)
83
+ .all(...params);
84
+ const events = rows.map(eventRowToEnvelope);
85
+ const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0);
86
+ return { events, nextId };
87
+ }
88
+ /**
89
+ * Delete events older than `retentionDays` (default: 90). Safe to call from
90
+ * a maintenance cron; uses a single DELETE with an index-covered ts predicate.
91
+ *
92
+ * Returns the number of rows actually deleted so callers can emit an
93
+ * `events_purged` observability event. A non-positive or non-finite
94
+ * `retentionDays` is treated as "disabled" and returns 0 without scanning.
95
+ */
96
+ export function purgeOldEvents(db, retentionDays = 90) {
97
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0)
98
+ return 0;
99
+ const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
100
+ const result = db.prepare("DELETE FROM events WHERE ts < ?").run(cutoff);
101
+ // bun:sqlite's run() returns { changes, lastInsertRowid }. `changes` may be
102
+ // a number or bigint depending on the underlying lib; coerce to number for
103
+ // the metadata payload.
104
+ const changes = result.changes ?? 0;
105
+ return typeof changes === "bigint" ? Number(changes) : changes;
106
+ }
107
+ /**
108
+ * Import all events from an `events.jsonl` file into the `events` table.
109
+ *
110
+ * The old byte-offset `id` is NOT preserved — the database assigns new
111
+ * monotonic integer ids. Callers that persisted a byte-offset cursor must
112
+ * discard it after migration and use the returned `maxId` as the new cursor.
113
+ *
114
+ * **Idempotency**: each line is pre-checked against the `events` table using
115
+ * `(event_type, ts, ref, metadata_json)` as the duplicate key. Lines whose
116
+ * exact tuple is already present are skipped and reported as `skipped` in the
117
+ * return value. This makes the migration safe to re-run (the v0.7→v0.8
118
+ * migration guide recommends re-running the script as a recovery path; without
119
+ * this guard, every re-run would double-import the entire event log).
120
+ *
121
+ * Duplicate detection is per-import-tuple, not a table-wide UNIQUE constraint:
122
+ * the events table has no UNIQUE constraint at runtime so that
123
+ * `appendEvent` can write multiple events with the same ts (sub-millisecond
124
+ * bursts produce identical `(event_type, ts, ref)` triples in practice). The
125
+ * SELECT-first check is scoped to the import path only.
126
+ *
127
+ * The import is wrapped in a single transaction for atomicity.
128
+ *
129
+ * @param db - Open state.db connection.
130
+ * @param jsonlPath - Absolute path to the events.jsonl file to import.
131
+ * @returns Number of rows inserted, the max id assigned, and the
132
+ * count of rows skipped because an identical event already
133
+ * existed in the table.
134
+ */
135
+ export async function importEventsJsonl(db, jsonlPath) {
136
+ const { readFileSync, existsSync } = await import("node:fs");
137
+ if (!existsSync(jsonlPath)) {
138
+ return { imported: 0, maxId: 0, skipped: 0 };
139
+ }
140
+ const text = readFileSync(jsonlPath, "utf8");
141
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
142
+ let imported = 0;
143
+ let maxId = 0;
144
+ let skipped = 0;
145
+ const insertStmt = db.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
146
+ VALUES (?, ?, ?, ?)
147
+ RETURNING id`);
148
+ // Dedup pre-check: matches by the full tuple including metadata_json so an
149
+ // import is idempotent over identical rows but does not collide with two
150
+ // genuinely different events that happen to share (event_type, ts, ref).
151
+ //
152
+ // Uses IS for ref so two NULL refs compare equal (a plain `=` would treat
153
+ // NULL = NULL as NULL and the row would be re-inserted on every run).
154
+ const existsStmt = db.prepare(`SELECT 1 FROM events
155
+ WHERE event_type = ?
156
+ AND ts = ?
157
+ AND ref IS ?
158
+ AND metadata_json = ?
159
+ LIMIT 1`);
160
+ db.transaction(() => {
161
+ for (const line of lines) {
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(line);
165
+ }
166
+ catch {
167
+ continue; // skip malformed lines — same behaviour as readEvents()
168
+ }
169
+ const eventType = typeof parsed.eventType === "string" ? parsed.eventType : "unknown";
170
+ const ts = typeof parsed.ts === "string" ? parsed.ts : new Date().toISOString();
171
+ const ref = typeof parsed.ref === "string" ? parsed.ref : null;
172
+ const metadata = parsed.metadata !== undefined && typeof parsed.metadata === "object" ? JSON.stringify(parsed.metadata) : "{}";
173
+ const duplicate = existsStmt.get(eventType, ts, ref, metadata);
174
+ if (duplicate) {
175
+ skipped++;
176
+ continue;
177
+ }
178
+ const result = insertStmt.get(eventType, ts, ref, metadata);
179
+ if (result) {
180
+ imported++;
181
+ if (result.id > maxId)
182
+ maxId = result.id;
183
+ }
184
+ }
185
+ })();
186
+ return { imported, maxId, skipped };
187
+ }
@@ -0,0 +1,96 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Record (or update) one session's extract outcome. INSERT-OR-REPLACE so the
6
+ * row reflects the most recent run. The `content_hash` persisted here is what
7
+ * the NEXT run compares against (#602): a byte-identical session is skipped, a
8
+ * changed session is re-processed, and a NULL-backfill row becomes hash-stable
9
+ * after its one reprocess. `session_ended_at` is still written for
10
+ * telemetry/forensics but is no longer the skip authority.
11
+ */
12
+ export function upsertExtractedSession(db, input) {
13
+ const endedAtIso = typeof input.sessionEndedAt === "number" && Number.isFinite(input.sessionEndedAt)
14
+ ? new Date(input.sessionEndedAt).toISOString()
15
+ : null;
16
+ db.prepare(`
17
+ INSERT OR REPLACE INTO extract_sessions_seen
18
+ (harness, session_id, processed_at, session_ended_at, outcome,
19
+ candidate_count, proposal_count, rationale, source_run, metadata_json,
20
+ content_hash)
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
+ `).run(input.harness, input.sessionId, input.processedAt, endedAtIso, input.outcome, input.candidateCount, input.proposalCount, input.rationale ?? null, input.sourceRun ?? null, JSON.stringify(input.metadata ?? {}), input.contentHash);
23
+ }
24
+ /**
25
+ * Fetch a single session's last extract record, or `undefined` when the
26
+ * session has never been processed.
27
+ */
28
+ export function getExtractedSession(db, harness, sessionId) {
29
+ // bun:sqlite returns null (not undefined) when no row matches — normalize so
30
+ // callers can rely on `if (!row)` and `toBeUndefined()` equivalently.
31
+ const row = db
32
+ .prepare("SELECT * FROM extract_sessions_seen WHERE harness = ? AND session_id = ?")
33
+ .get(harness, sessionId);
34
+ return row ?? undefined;
35
+ }
36
+ /**
37
+ * Bulk-fetch session-extract status for a list of sessionIds in one harness.
38
+ * Returns a Map keyed by sessionId so callers can do O(1) lookups while
39
+ * iterating the discovery list.
40
+ */
41
+ export function getExtractedSessionsMap(db, harness, sessionIds) {
42
+ const out = new Map();
43
+ if (sessionIds.length === 0)
44
+ return out;
45
+ // SQLite has a ~999 param ceiling; chunk if a caller ever exceeds that.
46
+ const CHUNK = 500;
47
+ for (let i = 0; i < sessionIds.length; i += CHUNK) {
48
+ const chunk = sessionIds.slice(i, i + CHUNK);
49
+ const placeholders = chunk.map(() => "?").join(",");
50
+ const rows = db
51
+ .prepare(`SELECT * FROM extract_sessions_seen
52
+ WHERE harness = ? AND session_id IN (${placeholders})`)
53
+ .all(harness, ...chunk);
54
+ for (const row of rows)
55
+ out.set(row.session_id, row);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * The most recent extract-run time for a harness — `MAX(processed_at)` across
61
+ * its ledger rows, as ms epoch — or `null` when the harness has never been
62
+ * extracted. Used to default the discovery window to "since the last run" so an
63
+ * intermittently-online host that was off for days still rediscovers sessions
64
+ * that ended during the gap (the content-hash ledger keeps the widened window
65
+ * free of redundant LLM cost).
66
+ */
67
+ export function getLastExtractRunAt(db, harness) {
68
+ const row = db
69
+ .prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?")
70
+ .get(harness);
71
+ if (!row?.last)
72
+ return null;
73
+ const ms = Date.parse(row.last);
74
+ return Number.isFinite(ms) ? ms : null;
75
+ }
76
+ /**
77
+ * Decide whether a session should be skipped because the extractor has already
78
+ * processed BYTE-IDENTICAL content (#602). The skip authority is the content
79
+ * hash, NOT `session_ended_at` — this is clock-independent, so it is immune to
80
+ * the clock-skew / out-of-order-endedAt problems that caused the Jun 11-12
81
+ * double-extract + over-throttle incident.
82
+ *
83
+ * Rules:
84
+ * - no prior row → `false` (never seen → process; AC3).
85
+ * - prior.content_hash == null → `false` (legacy / hash-less row → process
86
+ * exactly once to backfill the hash, then it becomes hash-stable; AC4).
87
+ * - hashes equal → `true` (unchanged content → skip; AC1).
88
+ * - hashes differ → `false` (changed content → re-process; AC2).
89
+ */
90
+ export function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
91
+ if (!prior)
92
+ return false;
93
+ if (prior.content_hash == null)
94
+ return false;
95
+ return prior.content_hash === currentContentHash;
96
+ }
@@ -0,0 +1,146 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { classifyImproveAction } from "../../core/improve-types.js";
5
+ // ── Per-phase gate threshold store (Migration 012) ───────────────────────────
6
+ /**
7
+ * Read the persisted auto-tuned threshold for a gate phase.
8
+ *
9
+ * Returns `undefined` when no row exists yet (first run, or the phase has
10
+ * never been tuned). The caller falls back to the global `options.autoAccept`
11
+ * in that case.
12
+ */
13
+ export function getPhaseThreshold(db, phase) {
14
+ const row = db.prepare("SELECT threshold FROM improve_gate_thresholds WHERE phase = ?").get(phase);
15
+ return row?.threshold;
16
+ }
17
+ /**
18
+ * Persist the auto-tuned threshold for a gate phase.
19
+ * Uses INSERT OR REPLACE so the call is idempotent (upsert semantics).
20
+ */
21
+ export function persistPhaseThreshold(db, phase, threshold) {
22
+ db.prepare(`INSERT OR REPLACE INTO improve_gate_thresholds (phase, threshold, updated_at)
23
+ VALUES (?, ?, ?)`).run(phase, Math.round(threshold), Date.now());
24
+ }
25
+ /**
26
+ * Compute the cheap aggregate metrics blob from a full improve result.
27
+ *
28
+ * Pure function — no I/O. Used by {@link recordImproveRun} to populate
29
+ * `metrics_json`. Exposed for tests and for any future call site that wants
30
+ * the same aggregation logic without hitting state.db.
31
+ */
32
+ export function computeImproveRunMetrics(result) {
33
+ const plannedCount = Array.isArray(result.plannedRefs) ? result.plannedRefs.length : 0;
34
+ const actions = Array.isArray(result.actions) ? result.actions : [];
35
+ const actionsCount = actions.length;
36
+ let acceptedCount = 0;
37
+ let rejectedCount = 0;
38
+ let skippedCount = 0;
39
+ let autoAcceptedCount = 0;
40
+ let errorCount = 0;
41
+ for (const action of actions) {
42
+ // Bucketing delegated to the shared classifyImproveAction so this aggregate
43
+ // and the improve_completed event in improve.ts can never disagree, and so a
44
+ // new union variant is a compile error rather than a silent drop. Gated skips
45
+ // (cooldown / signal-delta / distill pool-delta) bucket to "skipped", NOT
46
+ // "rejected" — only a guard-rejected produced change is a true rejection.
47
+ // "noop" (memory-prune) is intentionally counted in none of the buckets.
48
+ switch (classifyImproveAction(action.mode)) {
49
+ case "accepted":
50
+ acceptedCount++;
51
+ break;
52
+ case "rejected":
53
+ rejectedCount++;
54
+ break;
55
+ case "skipped":
56
+ skippedCount++;
57
+ break;
58
+ case "error":
59
+ errorCount++;
60
+ break;
61
+ case "noop":
62
+ break;
63
+ }
64
+ // Legacy: pre-gate action results may carry autoAccepted: true (reflect path).
65
+ const r = action.result;
66
+ if (r && r.autoAccepted === true)
67
+ autoAcceptedCount++;
68
+ }
69
+ // Add gate-promoted count from the unified PostPhaseAutoAcceptGate (all phases).
70
+ autoAcceptedCount += result.gateAutoAcceptedCount ?? 0;
71
+ // C1 (13-bus-factor): distill-skipped rows are folded into the bounded
72
+ // `distillSkipped` aggregate and no longer live in `actions`. Add the
73
+ // aggregate total to the skipped + total-actions counters so metrics_json
74
+ // reports the same numbers as before the fold. (Legacy rows that still carry
75
+ // per-ref distill-skipped in `actions` have no aggregate, so they are counted
76
+ // by the classify loop above — never double-counted.)
77
+ const distillSkippedTotal = result.distillSkipped?.total ?? 0;
78
+ skippedCount += distillSkippedTotal;
79
+ return {
80
+ plannedCount,
81
+ actionsCount: actionsCount + distillSkippedTotal,
82
+ acceptedCount,
83
+ rejectedCount,
84
+ skippedCount,
85
+ autoAcceptedCount,
86
+ errorCount,
87
+ };
88
+ }
89
+ /**
90
+ * Insert a single improve-run row into `improve_runs`. Uses parameterised SQL.
91
+ *
92
+ * Idempotency: the table's PRIMARY KEY is `id`, so re-running with the same
93
+ * runId would error. Callers mint a fresh runId per invocation via
94
+ * {@link buildImproveRunId} so this is not a concern in practice — but the
95
+ * default behaviour is INSERT (not REPLACE) so accidental dupes surface as
96
+ * a SQLite constraint error rather than silently overwriting a prior record.
97
+ *
98
+ * The `metrics` parameter defaults to the output of
99
+ * {@link computeImproveRunMetrics} when not supplied. Pass an explicit
100
+ * `metrics` object to override the derivation (e.g. tests).
101
+ */
102
+ export function recordImproveRun(db, input) {
103
+ const metricsObj = input.metrics ?? computeImproveRunMetrics(input.result);
104
+ db.prepare(`
105
+ INSERT INTO improve_runs
106
+ (id, started_at, completed_at, stash_dir, dry_run, profile,
107
+ scope_mode, scope_value, guidance, ok, result_json, metrics_json, metadata_json)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ `).run(input.id, input.startedAt, input.completedAt, input.stashDir, input.dryRun ? 1 : 0, input.profile, input.scopeMode, input.scopeValue, input.guidance, input.ok ? 1 : 0, JSON.stringify(input.result), JSON.stringify(metricsObj), JSON.stringify(input.metadata ?? {}));
110
+ }
111
+ /**
112
+ * Read real (non-dry-run) improve_runs rows whose `started_at` falls in the
113
+ * window `[since, until)`. When `until` is omitted the window is open-ended
114
+ * (`started_at >= since`). Rows are returned newest-first (`ORDER BY
115
+ * started_at DESC`).
116
+ *
117
+ * Owns the SQL formerly inlined in commands/health.ts (`loadImproveRunRows`).
118
+ * The `dry_run = 0` filter is first-class so dry-run probes never pollute
119
+ * productivity audits.
120
+ *
121
+ * Connection-lifetime rule (WS5): `.all()` fully materializes the result set
122
+ * into a plain array before returning — no live cursor escapes the caller's
123
+ * `openStateDatabase` scope.
124
+ */
125
+ export function queryImproveRuns(db, since, until) {
126
+ const sql = until
127
+ ? "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND started_at < ? AND dry_run = 0 ORDER BY started_at DESC"
128
+ : "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND dry_run = 0 ORDER BY started_at DESC";
129
+ return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since));
130
+ }
131
+ /**
132
+ * Delete improve_runs rows older than `retentionDays` (default: 90). Mirrors
133
+ * {@link purgeOldEvents} — same default, same return shape (number of rows
134
+ * actually deleted), same disabled-when-non-finite semantics.
135
+ *
136
+ * Safe to call from the improve post-loop maintenance pass alongside
137
+ * `purgeOldEvents(db, retentionDays)`.
138
+ */
139
+ export function purgeOldImproveRuns(db, retentionDays = 90) {
140
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0)
141
+ return 0;
142
+ const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
143
+ const result = db.prepare("DELETE FROM improve_runs WHERE started_at < ?").run(cutoff);
144
+ const changes = result.changes ?? 0;
145
+ return typeof changes === "bigint" ? Number(changes) : changes;
146
+ }
@@ -1,8 +1,15 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { closeDatabase, openExistingDatabase } from "../../indexer/db/db.js";
4
+ import { openExistingDatabase } from "../../indexer/db/db.js";
5
5
  import { resolveStorageLocations } from "../locations.js";
6
+ import { withManagedDb } from "../managed-db.js";
7
+ /**
8
+ * Busy-timeout (ms) for read-path telemetry writers. Small on purpose: a
9
+ * usage-event insert contending with a background reindex should be dropped,
10
+ * not waited on for the default 30s.
11
+ */
12
+ export const TELEMETRY_BUSY_TIMEOUT_MS = 250;
6
13
  /**
7
14
  * Scoped-resource (loan pattern) helper for the index database (`index.db`).
8
15
  *
@@ -32,12 +39,11 @@ import { resolveStorageLocations } from "../locations.js";
32
39
  * @param fn Receives the open index database; must finish all DB work before returning.
33
40
  * @returns Whatever `fn` returns.
34
41
  */
35
- export function withIndexDb(fn) {
36
- const db = openExistingDatabase(resolveStorageLocations().indexDb);
37
- try {
42
+ export function withIndexDb(fn, opts) {
43
+ return withManagedDb(() => openExistingDatabase(resolveStorageLocations().indexDb), (db) => {
44
+ if (opts?.busyTimeoutMs !== undefined) {
45
+ db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts.busyTimeoutMs))}`);
46
+ }
38
47
  return fn(db);
39
- }
40
- finally {
41
- closeDatabase(db);
42
- }
48
+ });
43
49
  }
@@ -0,0 +1,220 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Convert a raw `ProposalRow` to the public `Proposal` shape.
6
+ */
7
+ export function proposalRowToProposal(row) {
8
+ let frontmatter;
9
+ if (row.frontmatter_json) {
10
+ try {
11
+ frontmatter = JSON.parse(row.frontmatter_json);
12
+ }
13
+ catch {
14
+ /* ignore corrupt frontmatter JSON */
15
+ }
16
+ }
17
+ let meta = {};
18
+ try {
19
+ meta = JSON.parse(row.metadata_json);
20
+ }
21
+ catch {
22
+ /* ignore */
23
+ }
24
+ return {
25
+ id: row.id,
26
+ ref: row.ref,
27
+ status: row.status,
28
+ source: row.source,
29
+ ...(typeof meta.sourceRun === "string" ? { sourceRun: meta.sourceRun } : {}),
30
+ createdAt: row.created_at,
31
+ updatedAt: row.updated_at,
32
+ payload: {
33
+ content: row.content,
34
+ ...(frontmatter !== undefined ? { frontmatter } : {}),
35
+ },
36
+ ...(meta.review !== undefined ? { review: meta.review } : {}),
37
+ ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
38
+ ...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
39
+ ...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
40
+ ...(typeof meta.eligibilitySource === "string"
41
+ ? { eligibilitySource: meta.eligibilitySource }
42
+ : {}),
43
+ };
44
+ }
45
+ /**
46
+ * Convert a public `Proposal` to column values ready for an INSERT/UPDATE.
47
+ * The `stash_dir` comes from the call site (proposals.ts has it in scope).
48
+ */
49
+ export function proposalToRowValues(proposal, stashDir) {
50
+ // Fields that have no dedicated column live in metadata_json.
51
+ const metaObj = {};
52
+ if (proposal.sourceRun !== undefined)
53
+ metaObj.sourceRun = proposal.sourceRun;
54
+ if (proposal.review !== undefined)
55
+ metaObj.review = proposal.review;
56
+ if (proposal.confidence !== undefined)
57
+ metaObj.confidence = proposal.confidence;
58
+ if (proposal.gateDecision !== undefined)
59
+ metaObj.gateDecision = proposal.gateDecision;
60
+ if (proposal.backupContent !== undefined)
61
+ metaObj.backupContent = proposal.backupContent;
62
+ if (proposal.eligibilitySource !== undefined)
63
+ metaObj.eligibilitySource = proposal.eligibilitySource;
64
+ return {
65
+ id: proposal.id,
66
+ stash_dir: stashDir,
67
+ ref: proposal.ref,
68
+ status: proposal.status,
69
+ source: proposal.source,
70
+ created_at: proposal.createdAt,
71
+ updated_at: proposal.updatedAt,
72
+ content: proposal.payload.content,
73
+ frontmatter_json: proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null,
74
+ metadata_json: JSON.stringify(metaObj),
75
+ };
76
+ }
77
+ /**
78
+ * Upsert a proposal row. Called by the proposal write path when state.db is
79
+ * the active backend.
80
+ */
81
+ export function upsertProposal(db, proposal, stashDir) {
82
+ const v = proposalToRowValues(proposal, stashDir);
83
+ db.prepare(`
84
+ INSERT INTO proposals
85
+ (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ ON CONFLICT(id) DO UPDATE SET
88
+ stash_dir = excluded.stash_dir,
89
+ ref = excluded.ref,
90
+ status = excluded.status,
91
+ source = excluded.source,
92
+ updated_at = excluded.updated_at,
93
+ content = excluded.content,
94
+ frontmatter_json = excluded.frontmatter_json,
95
+ metadata_json = excluded.metadata_json
96
+ `).run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
97
+ }
98
+ /**
99
+ * List proposals, optionally filtered by stashDir, status, and/or ref.
100
+ *
101
+ * Results are ordered by `created_at ASC` (matching the historical
102
+ * `listProposals()` sort), with `rowid` as a deterministic tiebreak so two
103
+ * proposals created in the same millisecond list in insertion order.
104
+ */
105
+ export function listStateProposals(db, options = {}) {
106
+ const conditions = [];
107
+ const params = [];
108
+ if (options.stashDir) {
109
+ conditions.push("stash_dir = ?");
110
+ params.push(options.stashDir);
111
+ }
112
+ if (options.status) {
113
+ conditions.push("status = ?");
114
+ params.push(options.status);
115
+ }
116
+ if (options.ref) {
117
+ conditions.push("ref = ?");
118
+ params.push(options.ref);
119
+ }
120
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
121
+ const rows = db
122
+ .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
123
+ content, frontmatter_json, metadata_json
124
+ FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
125
+ .all(...params);
126
+ return rows.map(proposalRowToProposal);
127
+ }
128
+ /**
129
+ * Read every proposal's `gateDecision` record across all stashes (#612).
130
+ *
131
+ * Calibration reads the auto-accept gate's per-proposal decisions regardless of
132
+ * the proposal's current lifecycle status — a proposal that was auto-accepted
133
+ * is now `accepted`, an auto-rejected one stays `pending`, so filtering by
134
+ * status would drop half the join. Rows without a `gateDecision` (created
135
+ * before #577, or never gated) are skipped. The result is ordered by
136
+ * `decidedAt ASC` for deterministic downstream aggregation, falling back to
137
+ * `created_at` ordering from the SQL layer for rows with equal/missing
138
+ * timestamps.
139
+ */
140
+ export function listProposalGateDecisions(db) {
141
+ const rows = db.prepare("SELECT metadata_json FROM proposals ORDER BY created_at ASC, rowid ASC").all();
142
+ const decisions = [];
143
+ for (const row of rows) {
144
+ let meta;
145
+ try {
146
+ meta = JSON.parse(row.metadata_json);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ const decision = meta.gateDecision;
152
+ if (decision && typeof decision === "object" && typeof decision.outcome === "string") {
153
+ decisions.push(decision);
154
+ }
155
+ }
156
+ decisions.sort((a, b) => new Date(a.decidedAt).getTime() - new Date(b.decidedAt).getTime());
157
+ return decisions;
158
+ }
159
+ /**
160
+ * Look up a single proposal by id, optionally scoped to one stash root.
161
+ * Returns undefined when not found.
162
+ */
163
+ export function getStateProposal(db, id, stashDir) {
164
+ const sql = `SELECT id, stash_dir, ref, status, source, created_at, updated_at,
165
+ content, frontmatter_json, metadata_json
166
+ FROM proposals WHERE id = ?${stashDir ? " AND stash_dir = ?" : ""}`;
167
+ const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
168
+ return row ? proposalRowToProposal(row) : undefined;
169
+ }
170
+ /**
171
+ * Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
172
+ * Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
173
+ * prefix resolution is deliberately scoped to the live (pending) queue,
174
+ * mirroring the historical behaviour of scanning only the live directory.
175
+ *
176
+ * `%` / `_` / `\` in the prefix are escaped so the LIKE pattern is literal.
177
+ */
178
+ export function listStateProposalIdsByPrefix(db, stashDir, idPrefix) {
179
+ const escaped = idPrefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
180
+ const rows = db
181
+ .prepare(`SELECT id FROM proposals
182
+ WHERE stash_dir = ? AND status = 'pending' AND id LIKE ? ESCAPE '\\'
183
+ ORDER BY id ASC`)
184
+ .all(stashDir, `${escaped}%`);
185
+ return rows.map((r) => r.id);
186
+ }
187
+ /**
188
+ * Whether the legacy filesystem proposal import has already run for `stashDir`.
189
+ * See migration 005 (`proposal_fs_imports`).
190
+ */
191
+ export function hasImportedFsProposals(db, stashDir) {
192
+ // Drivers disagree on the no-row sentinel (bun:sqlite → null,
193
+ // better-sqlite3 → undefined) — Boolean() covers both.
194
+ return Boolean(db.prepare("SELECT 1 FROM proposal_fs_imports WHERE stash_dir = ?").get(stashDir));
195
+ }
196
+ /**
197
+ * Record that the legacy filesystem proposal import completed for `stashDir`
198
+ * so subsequent invocations skip the directory walk. INSERT OR REPLACE keeps
199
+ * the call idempotent.
200
+ */
201
+ export function recordFsProposalsImport(db, stashDir, importedCount) {
202
+ db.prepare("INSERT OR REPLACE INTO proposal_fs_imports (stash_dir, imported_at, imported_count) VALUES (?, ?, ?)").run(stashDir, new Date().toISOString(), importedCount);
203
+ }
204
+ /**
205
+ * Insert a proposal row ONLY when the id is not already present (used by the
206
+ * legacy filesystem import so re-runs never clobber rows that have since been
207
+ * mutated through the canonical store). Returns true when a row was inserted.
208
+ */
209
+ export function insertProposalIfAbsent(db, proposal, stashDir) {
210
+ const v = proposalToRowValues(proposal, stashDir);
211
+ const result = db
212
+ .prepare(`
213
+ INSERT OR IGNORE INTO proposals
214
+ (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
216
+ `)
217
+ .run(v.id, v.stash_dir, v.ref, v.status, v.source, v.created_at, v.updated_at, v.content, v.frontmatter_json, v.metadata_json);
218
+ const changes = result.changes ?? 0;
219
+ return Number(changes) > 0;
220
+ }