akm-cli 0.7.5 → 0.8.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +86 -0
  3. package/dist/cli.js +1023 -521
  4. package/dist/commands/agent-dispatch.js +107 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +812 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +218 -43
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1161 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +291 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +145 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/vault-key-rules.js +67 -0
  38. package/dist/commands/lint/workflow-linter.js +53 -0
  39. package/dist/commands/lint.js +1 -0
  40. package/dist/commands/proposal.js +8 -7
  41. package/dist/commands/propose.js +71 -28
  42. package/dist/commands/reflect.js +135 -35
  43. package/dist/commands/registry-search.js +2 -2
  44. package/dist/commands/remember.js +54 -0
  45. package/dist/commands/schema-repair.js +130 -0
  46. package/dist/commands/search.js +21 -5
  47. package/dist/commands/show.js +125 -20
  48. package/dist/commands/source-add.js +10 -10
  49. package/dist/commands/source-manage.js +11 -19
  50. package/dist/commands/tasks.js +385 -0
  51. package/dist/commands/url-checker.js +39 -0
  52. package/dist/commands/vault.js +168 -77
  53. package/dist/core/action-contributors.js +25 -0
  54. package/dist/core/asset-ref.js +4 -0
  55. package/dist/core/asset-registry.js +4 -16
  56. package/dist/core/asset-spec.js +10 -0
  57. package/dist/core/common.js +100 -0
  58. package/dist/core/concurrent.js +22 -0
  59. package/dist/core/config.js +233 -133
  60. package/dist/core/events.js +73 -126
  61. package/dist/core/frontmatter.js +0 -6
  62. package/dist/core/markdown.js +17 -0
  63. package/dist/core/memory-improve.js +678 -0
  64. package/dist/core/parse.js +155 -0
  65. package/dist/core/paths.js +101 -3
  66. package/dist/core/proposal-validators.js +61 -0
  67. package/dist/core/proposals.js +49 -38
  68. package/dist/core/state-db.js +731 -0
  69. package/dist/core/time.js +51 -0
  70. package/dist/core/warn.js +59 -1
  71. package/dist/indexer/db-search.js +52 -238
  72. package/dist/indexer/db.js +403 -54
  73. package/dist/indexer/ensure-index.js +61 -0
  74. package/dist/indexer/graph-boost.js +247 -94
  75. package/dist/indexer/graph-db.js +201 -0
  76. package/dist/indexer/graph-dedup.js +99 -0
  77. package/dist/indexer/graph-extraction.js +409 -76
  78. package/dist/indexer/index-context.js +10 -0
  79. package/dist/indexer/indexer.js +456 -290
  80. package/dist/indexer/llm-cache.js +47 -0
  81. package/dist/indexer/matchers.js +124 -160
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +196 -197
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/builders.js +109 -0
  93. package/dist/integrations/agent/config.js +203 -3
  94. package/dist/integrations/agent/index.js +5 -2
  95. package/dist/integrations/agent/model-aliases.js +63 -0
  96. package/dist/integrations/agent/profiles.js +67 -5
  97. package/dist/integrations/agent/prompts.js +77 -72
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +93 -22
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +220 -256
  116. package/dist/output/shapes.js +101 -93
  117. package/dist/output/text.js +256 -17
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/registry/resolve.js +8 -16
  121. package/dist/setup/setup.js +510 -11
  122. package/dist/sources/provider-factory.js +2 -1
  123. package/dist/sources/providers/filesystem.js +16 -23
  124. package/dist/sources/providers/git.js +4 -5
  125. package/dist/sources/providers/website.js +15 -22
  126. package/dist/sources/website-ingest.js +4 -0
  127. package/dist/tasks/backends/cron.js +200 -0
  128. package/dist/tasks/backends/exec-utils.js +25 -0
  129. package/dist/tasks/backends/index.js +32 -0
  130. package/dist/tasks/backends/launchd-template.xml +19 -0
  131. package/dist/tasks/backends/launchd.js +184 -0
  132. package/dist/tasks/backends/schtasks-template.xml +29 -0
  133. package/dist/tasks/backends/schtasks.js +212 -0
  134. package/dist/tasks/parser.js +198 -0
  135. package/dist/tasks/resolveAkmBin.js +84 -0
  136. package/dist/tasks/runner.js +432 -0
  137. package/dist/tasks/schedule.js +208 -0
  138. package/dist/tasks/schema.js +13 -0
  139. package/dist/tasks/validator.js +59 -0
  140. package/dist/wiki/index-template.md +12 -0
  141. package/dist/wiki/ingest-workflow-template.md +54 -0
  142. package/dist/wiki/log-template.md +8 -0
  143. package/dist/wiki/schema-template.md +61 -0
  144. package/dist/wiki/wiki-templates.js +12 -0
  145. package/dist/wiki/wiki.js +10 -61
  146. package/dist/workflows/authoring.js +5 -25
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +59 -91
  149. package/dist/workflows/validator.js +1 -1
  150. package/dist/workflows/workflow-template.md +24 -0
  151. package/docs/README.md +5 -2
  152. package/docs/migration/release-notes/0.7.0.md +1 -1
  153. package/docs/migration/release-notes/0.8.0.md +43 -0
  154. package/package.json +3 -2
  155. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,731 @@
1
+ /**
2
+ * state.db — Durable SQLite database for non-regenerable akm state.
3
+ *
4
+ * This module owns THREE tables that replace flat-file storage:
5
+ *
6
+ * events — replaces events.jsonl (append-only event bus)
7
+ * proposals — replaces per-uuid JSON directories under .akm/proposals/
8
+ * task_history — replaces per-task JSONL files under <cacheDir>/tasks/history/
9
+ *
10
+ * ## Why a separate database from index.db
11
+ *
12
+ * index.db uses a single DB_VERSION integer: when the version changes it drops
13
+ * ALL tables and recreates them. That is acceptable for the search index because
14
+ * every entry is fully regenerable from the stash on disk. Events, proposals, and
15
+ * task history are NON-REGENERABLE — losing them is data loss. They must live in
16
+ * a database whose schema evolves via incremental, additive migrations that never
17
+ * drop rows.
18
+ *
19
+ * ## Migration-safety contract
20
+ *
21
+ * The `schema_migrations` table records every applied migration by a stable string
22
+ * ID. `runMigrations(db)` is idempotent: new installs run all migrations in order;
23
+ * upgrades run only the ones not yet applied. No migration may DROP a table that
24
+ * holds durable data, RENAME a column, or change a column's type.
25
+ *
26
+ * Permitted schema evolution operations (always migration-safe in SQLite):
27
+ * - ALTER TABLE … ADD COLUMN <name> <type> DEFAULT <value>
28
+ * - CREATE INDEX IF NOT EXISTS …
29
+ * - CREATE TABLE IF NOT EXISTS … (additive new tables)
30
+ *
31
+ * ## Schema design: indexed columns vs. metadata_json
32
+ *
33
+ * Each table holds only the columns needed for indexed queries as first-class
34
+ * columns. All other fields live in a `metadata_json TEXT` column (a JSON object).
35
+ * New fields can be appended to the JSON blob at any time without touching the
36
+ * DDL. This is the same pattern used by `usage_events.metadata` in index.db and
37
+ * by the original events.jsonl format (the `metadata` field was always free-form
38
+ * JSON).
39
+ *
40
+ * ## WAL mode
41
+ *
42
+ * SQLite WAL mode allows concurrent readers while a writer is active and makes
43
+ * crashes safe (the WAL is replayed on next open). The O_APPEND multi-writer model
44
+ * of events.jsonl is replaced by WAL-mode serialised writes — acceptable because
45
+ * CLI commands are almost always single-writer.
46
+ *
47
+ * @module state-db
48
+ */
49
+ import { Database } from "bun:sqlite";
50
+ import fs from "node:fs";
51
+ import path from "node:path";
52
+ import { getDataDir } from "./paths";
53
+ import { error } from "./warn";
54
+ // ── Path helper ──────────────────────────────────────────────────────────────
55
+ /**
56
+ * Default path: `<dataDir>/state.db`.
57
+ * Respects the same `AKM_DATA_DIR` / XDG_DATA_HOME env-isolation as `getDbPath()` so
58
+ * cooperating processes sharing a data root automatically share the same
59
+ * state database.
60
+ */
61
+ export function getStateDbPath() {
62
+ return path.join(getDataDir(), "state.db");
63
+ }
64
+ // ── Database open ────────────────────────────────────────────────────────────
65
+ /**
66
+ * Open (and initialise / migrate) the state database.
67
+ *
68
+ * @param dbPath - Override the database file path. Pass a tmpdir path in tests
69
+ * to avoid touching the real user cache. Mirrors the `filePath` test seam
70
+ * on `EventsContext`.
71
+ *
72
+ * PRAGMA rationale:
73
+ *
74
+ * journal_mode = WAL
75
+ * Write-Ahead Logging: readers never block writers and vice-versa. Crashes
76
+ * are safe — the WAL is replayed on next open. Required for concurrent CLI
77
+ * invocations that may read while another writes.
78
+ *
79
+ * foreign_keys = ON
80
+ * Enforces FK constraints at runtime. SQLite disables them by default for
81
+ * backwards compatibility; enabling them prevents orphaned rows in tables
82
+ * that reference each other (not used in v1 schema but guards future ones).
83
+ *
84
+ * busy_timeout = 5000
85
+ * When another connection holds a write lock, SQLite retries for up to
86
+ * 5 000 ms before returning SQLITE_BUSY. Without this, the default timeout
87
+ * is 0 ms — any concurrent writer causes an immediate error. 5 s matches
88
+ * the same value used in openDatabase() for index.db.
89
+ */
90
+ export function openStateDatabase(dbPath) {
91
+ const resolvedPath = dbPath ?? getStateDbPath();
92
+ const dir = path.dirname(resolvedPath);
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+ const db = new Database(resolvedPath);
97
+ // PRAGMAs must run before any DDL or DML.
98
+ db.exec("PRAGMA journal_mode = WAL");
99
+ db.exec("PRAGMA foreign_keys = ON");
100
+ db.exec("PRAGMA busy_timeout = 5000");
101
+ runMigrations(db);
102
+ return db;
103
+ }
104
+ /**
105
+ * All migrations in application order. New migrations are APPENDED to this
106
+ * array — never inserted in the middle or reordered.
107
+ *
108
+ * @see Migration
109
+ */
110
+ const MIGRATIONS = [
111
+ // ── Migration 001 — initial schema ──────────────────────────────────────────
112
+ {
113
+ id: "001-initial-schema",
114
+ up: `
115
+ -- ── events ──────────────────────────────────────────────────────────────
116
+ --
117
+ -- Replaces events.jsonl. Indexed (query) columns:
118
+ -- id INTEGER PK — monotonic rowid; replaces byte-offset cursor.
119
+ -- Callers store this as "sinceId" for resume.
120
+ -- event_type TEXT — indexed; replaces the type filter in readEvents().
121
+ -- ts TEXT — ISO-8601 UTC ms; indexed for range queries.
122
+ -- ref TEXT — nullable asset ref; indexed for ref-scoped queries.
123
+ --
124
+ -- Extensible (metadata_json) columns:
125
+ -- metadata_json TEXT — JSON object storing all non-indexed payload
126
+ -- fields (tags, any future structured fields).
127
+ -- Maps directly to EventEnvelope.metadata.
128
+ --
129
+ -- schema_version mirrors EventEnvelope.schemaVersion — always 1 for v1
130
+ -- rows. Stored as a column (not in the JSON blob) so future schema
131
+ -- changes can be detected and migrated row-by-row if ever needed.
132
+ --
133
+ -- TTL: rows where ts < NOW() - 90 days can be deleted by a maintenance job.
134
+ -- No automatic deletion occurs here — callers call purgeOldEvents().
135
+ --
136
+ -- ADD COLUMN extension points (future migrations):
137
+ -- ALTER TABLE events ADD COLUMN stash_dir TEXT DEFAULT NULL;
138
+ -- ALTER TABLE events ADD COLUMN correlation_id TEXT DEFAULT NULL;
139
+ -- ALTER TABLE events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;
140
+ --
141
+ CREATE TABLE IF NOT EXISTS events (
142
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
143
+ event_type TEXT NOT NULL,
144
+ ts TEXT NOT NULL,
145
+ ref TEXT,
146
+ metadata_json TEXT NOT NULL DEFAULT '{}'
147
+ );
148
+
149
+ -- Query patterns supported by these indexes:
150
+ -- SELECT … WHERE event_type = ? → idx_events_type
151
+ -- SELECT … WHERE ref = ? → idx_events_ref
152
+ -- SELECT … WHERE ts >= ? AND ts <= ? → idx_events_ts
153
+ -- SELECT … WHERE event_type = ? AND ref = ? → idx_events_type (prefix scan) + filter
154
+ -- SELECT … WHERE id > ? → PK (rowid) — no extra index needed
155
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
156
+ CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
157
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
158
+
159
+ -- ── proposals ────────────────────────────────────────────────────────────
160
+ --
161
+ -- Replaces per-uuid JSON directories under <stashDir>/.akm/proposals/.
162
+ --
163
+ -- Indexed (query) columns:
164
+ -- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
165
+ -- stash_dir TEXT — absolute stash root; multi-stash installs need
166
+ -- this to partition proposal lists per stash.
167
+ -- ref TEXT — target asset ref (e.g. "lesson:alpha");
168
+ -- indexed for ref-scoped queue views.
169
+ -- status TEXT — "pending" | "accepted" | "rejected"; indexed
170
+ -- so pending-queue queries are fast.
171
+ -- source TEXT — human-readable origin tag (e.g. "reflect").
172
+ -- created_at TEXT — ISO-8601; used for ORDER BY created_at ASC.
173
+ -- updated_at TEXT — ISO-8601; updated on accept/reject.
174
+ --
175
+ -- Large payload columns (NOT indexed):
176
+ -- content TEXT — full markdown text; the proposal payload body.
177
+ -- frontmatter_json TEXT — JSON of parsed frontmatter (may be NULL when
178
+ -- the content has no frontmatter block).
179
+ --
180
+ -- Extensible (metadata_json) columns:
181
+ -- metadata_json TEXT — JSON object for future proposal fields.
182
+ -- Current fields stored here: sourceRun, review.
183
+ --
184
+ -- ADD COLUMN extension points (future migrations):
185
+ -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
186
+ -- ALTER TABLE proposals ADD COLUMN review_outcome TEXT DEFAULT NULL;
187
+ -- ALTER TABLE proposals ADD COLUMN review_reason TEXT DEFAULT NULL;
188
+ -- ALTER TABLE proposals ADD COLUMN review_decided_at TEXT DEFAULT NULL;
189
+ -- ALTER TABLE proposals ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
190
+ --
191
+ CREATE TABLE IF NOT EXISTS proposals (
192
+ id TEXT PRIMARY KEY,
193
+ stash_dir TEXT NOT NULL,
194
+ ref TEXT NOT NULL,
195
+ status TEXT NOT NULL DEFAULT 'pending',
196
+ source TEXT NOT NULL,
197
+ created_at TEXT NOT NULL,
198
+ updated_at TEXT NOT NULL,
199
+ content TEXT NOT NULL DEFAULT '',
200
+ frontmatter_json TEXT,
201
+ metadata_json TEXT NOT NULL DEFAULT '{}'
202
+ );
203
+
204
+ -- Query patterns:
205
+ -- SELECT … WHERE stash_dir = ? AND status = ? → idx_proposals_stash_status
206
+ -- SELECT … WHERE ref = ? AND status = ? → idx_proposals_ref_status
207
+ -- SELECT … WHERE id = ? → PK
208
+ CREATE INDEX IF NOT EXISTS idx_proposals_stash_status
209
+ ON proposals(stash_dir, status);
210
+ CREATE INDEX IF NOT EXISTS idx_proposals_ref_status
211
+ ON proposals(ref, status);
212
+
213
+ -- ── task_history ─────────────────────────────────────────────────────────
214
+ --
215
+ -- Replaces per-task JSONL files under <cacheDir>/tasks/history/.
216
+ --
217
+ -- Indexed (query) columns:
218
+ -- task_id TEXT PK — stable task identifier string.
219
+ -- status TEXT — terminal status (e.g. "completed", "failed",
220
+ -- "cancelled"); indexed for status-scoped queries.
221
+ -- started_at TEXT — ISO-8601; indexed for time-range queries.
222
+ -- target_kind TEXT — kind of the target entity (e.g. "issue",
223
+ -- "workflow", "agent"); indexed for kind-scoped queries.
224
+ -- target_ref TEXT — stable ref of the target entity; indexed for
225
+ -- per-target history lookups.
226
+ --
227
+ -- Non-indexed time columns:
228
+ -- completed_at TEXT — ISO-8601 or NULL if still running.
229
+ -- failed_at TEXT — ISO-8601 or NULL.
230
+ --
231
+ -- Non-indexed diagnostic columns:
232
+ -- log_path TEXT — absolute path to the task log file, if any.
233
+ --
234
+ -- Extensible (metadata_json) columns:
235
+ -- metadata_json TEXT — JSON object for future task fields (exit_code,
236
+ -- runner, priority, parent_task_id, …).
237
+ --
238
+ -- ADD COLUMN extension points (future migrations):
239
+ -- ALTER TABLE task_history ADD COLUMN exit_code INTEGER DEFAULT NULL;
240
+ -- ALTER TABLE task_history ADD COLUMN runner TEXT DEFAULT NULL;
241
+ -- ALTER TABLE task_history ADD COLUMN parent_task_id TEXT DEFAULT NULL;
242
+ -- ALTER TABLE task_history ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
243
+ --
244
+ CREATE TABLE IF NOT EXISTS task_history (
245
+ task_id TEXT PRIMARY KEY,
246
+ status TEXT NOT NULL,
247
+ started_at TEXT NOT NULL,
248
+ completed_at TEXT,
249
+ failed_at TEXT,
250
+ log_path TEXT,
251
+ target_kind TEXT,
252
+ target_ref TEXT,
253
+ metadata_json TEXT NOT NULL DEFAULT '{}'
254
+ );
255
+
256
+ -- Query patterns:
257
+ -- SELECT … WHERE task_id = ? → PK
258
+ -- SELECT … WHERE started_at >= ? AND started_at <= ? → idx_task_history_started
259
+ -- SELECT … WHERE target_kind = ? AND target_ref = ? → idx_task_history_target
260
+ -- SELECT … WHERE status = ? → idx_task_history_status
261
+ CREATE INDEX IF NOT EXISTS idx_task_history_started
262
+ ON task_history(started_at);
263
+ CREATE INDEX IF NOT EXISTS idx_task_history_target
264
+ ON task_history(target_kind, target_ref);
265
+ CREATE INDEX IF NOT EXISTS idx_task_history_status
266
+ ON task_history(status);
267
+ `,
268
+ },
269
+ // Migration 002 — fix task_history to be a true per-run log.
270
+ //
271
+ // Migration 001 used task_id as PRIMARY KEY, meaning each task had exactly
272
+ // one row and every new run overwrote the previous one. This silently
273
+ // discarded all historical runs — the opposite of a history table.
274
+ //
275
+ // This migration recreates the table with an AUTOINCREMENT id so each run
276
+ // appends a new row. The old single-row table is renamed to _old, the new
277
+ // table is created, data is copied, and the old table is dropped.
278
+ {
279
+ id: "002-task-history-per-run",
280
+ up: `
281
+ ALTER TABLE task_history RENAME TO task_history_v1;
282
+
283
+ CREATE TABLE task_history (
284
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
285
+ task_id TEXT NOT NULL,
286
+ status TEXT NOT NULL,
287
+ started_at TEXT NOT NULL,
288
+ completed_at TEXT,
289
+ failed_at TEXT,
290
+ log_path TEXT,
291
+ target_kind TEXT,
292
+ target_ref TEXT,
293
+ metadata_json TEXT NOT NULL DEFAULT '{}'
294
+ );
295
+
296
+ INSERT INTO task_history
297
+ (task_id, status, started_at, completed_at, failed_at,
298
+ log_path, target_kind, target_ref, metadata_json)
299
+ SELECT task_id, status, started_at, completed_at, failed_at,
300
+ log_path, target_kind, target_ref, metadata_json
301
+ FROM task_history_v1;
302
+
303
+ DROP TABLE task_history_v1;
304
+
305
+ -- Unique constraint: same task cannot have two runs with the same start time.
306
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_history_run
307
+ ON task_history(task_id, started_at);
308
+ CREATE INDEX IF NOT EXISTS idx_task_history_task_id
309
+ ON task_history(task_id);
310
+ CREATE INDEX IF NOT EXISTS idx_task_history_started
311
+ ON task_history(started_at);
312
+ CREATE INDEX IF NOT EXISTS idx_task_history_target
313
+ ON task_history(target_kind, target_ref);
314
+ CREATE INDEX IF NOT EXISTS idx_task_history_status
315
+ ON task_history(status);
316
+ `,
317
+ },
318
+ ];
319
+ /**
320
+ * Create the migrations table if it does not exist. This must be called
321
+ * unconditionally on every open so a fresh database bootstraps correctly.
322
+ */
323
+ function ensureMigrationsTable(db) {
324
+ db.exec(`
325
+ CREATE TABLE IF NOT EXISTS schema_migrations (
326
+ id TEXT PRIMARY KEY,
327
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
328
+ );
329
+ `);
330
+ }
331
+ /**
332
+ * Apply every pending migration in a single transaction per migration.
333
+ *
334
+ * Each migration is applied in its own transaction so a failure in migration N
335
+ * does not roll back already-applied migrations 1..N-1. The migration row is
336
+ * inserted AFTER the DDL succeeds, so a crash mid-migration leaves no row and
337
+ * the migration will be retried on next open (all DDL in `up` uses IF NOT
338
+ * EXISTS so the retry is safe).
339
+ *
340
+ * Called automatically by `openStateDatabase()`.
341
+ */
342
+ export function runMigrations(db) {
343
+ ensureMigrationsTable(db);
344
+ const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
345
+ const applied = new Set(appliedRows.map((r) => r.id));
346
+ for (const migration of MIGRATIONS) {
347
+ if (applied.has(migration.id))
348
+ continue;
349
+ db.transaction(() => {
350
+ db.exec(migration.up);
351
+ db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id);
352
+ })();
353
+ }
354
+ }
355
+ /**
356
+ * Convert a raw `EventRow` from the database to the public `EventEnvelope`
357
+ * interface used throughout the events module.
358
+ */
359
+ export function eventRowToEnvelope(row) {
360
+ let metadata;
361
+ try {
362
+ const parsed = JSON.parse(row.metadata_json);
363
+ // Only attach metadata when the JSON blob is non-empty so downstream
364
+ // consumers that check `envelope.metadata !== undefined` keep working.
365
+ if (Object.keys(parsed).length > 0) {
366
+ metadata = parsed;
367
+ }
368
+ }
369
+ catch {
370
+ // Corrupt JSON in the DB — treat as no metadata.
371
+ }
372
+ return {
373
+ schemaVersion: 1,
374
+ id: row.id,
375
+ ts: row.ts,
376
+ eventType: row.event_type,
377
+ ...(row.ref !== null ? { ref: row.ref } : {}),
378
+ ...(metadata !== undefined ? { metadata } : {}),
379
+ };
380
+ }
381
+ /**
382
+ * Convert a raw `ProposalRow` to the public `Proposal` shape.
383
+ */
384
+ export function proposalRowToProposal(row) {
385
+ let frontmatter;
386
+ if (row.frontmatter_json) {
387
+ try {
388
+ frontmatter = JSON.parse(row.frontmatter_json);
389
+ }
390
+ catch {
391
+ /* ignore corrupt frontmatter JSON */
392
+ }
393
+ }
394
+ let meta = {};
395
+ try {
396
+ meta = JSON.parse(row.metadata_json);
397
+ }
398
+ catch {
399
+ /* ignore */
400
+ }
401
+ return {
402
+ id: row.id,
403
+ ref: row.ref,
404
+ status: row.status,
405
+ source: row.source,
406
+ ...(typeof meta.sourceRun === "string" ? { sourceRun: meta.sourceRun } : {}),
407
+ createdAt: row.created_at,
408
+ updatedAt: row.updated_at,
409
+ payload: {
410
+ content: row.content,
411
+ ...(frontmatter !== undefined ? { frontmatter } : {}),
412
+ },
413
+ ...(meta.review !== undefined ? { review: meta.review } : {}),
414
+ };
415
+ }
416
+ /**
417
+ * Convert a public `Proposal` to column values ready for an INSERT/UPDATE.
418
+ * The `stash_dir` comes from the call site (proposals.ts has it in scope).
419
+ */
420
+ export function proposalToRowValues(proposal, stashDir) {
421
+ // Fields that have no dedicated column live in metadata_json.
422
+ const metaObj = {};
423
+ if (proposal.sourceRun !== undefined)
424
+ metaObj.sourceRun = proposal.sourceRun;
425
+ if (proposal.review !== undefined)
426
+ metaObj.review = proposal.review;
427
+ return {
428
+ id: proposal.id,
429
+ stash_dir: stashDir,
430
+ ref: proposal.ref,
431
+ status: proposal.status,
432
+ source: proposal.source,
433
+ created_at: proposal.createdAt,
434
+ updated_at: proposal.updatedAt,
435
+ content: proposal.payload.content,
436
+ frontmatter_json: proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null,
437
+ metadata_json: JSON.stringify(metaObj),
438
+ };
439
+ }
440
+ // ── events table helpers ─────────────────────────────────────────────────────
441
+ /**
442
+ * Insert a single event. Returns the auto-assigned monotonic rowid, which
443
+ * callers can store as a "sinceId" cursor for future `readEventsSince` calls.
444
+ *
445
+ * Best-effort: mirrors the behaviour of the old `appendEvent` — errors are
446
+ * caught and logged to stderr rather than propagated so observability never
447
+ * breaks mutation.
448
+ */
449
+ export function insertEvent(db, input) {
450
+ try {
451
+ const result = db
452
+ .prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
453
+ VALUES (?, ?, ?, ?)
454
+ RETURNING id`)
455
+ .get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata ?? {}));
456
+ return result?.id;
457
+ }
458
+ catch (err) {
459
+ const message = err instanceof Error ? err.message : String(err);
460
+ error(`akm: state.db event insert failed (${message})`);
461
+ return undefined;
462
+ }
463
+ }
464
+ /**
465
+ * Read events from the database matching the filter. Returns events in
466
+ * ascending id order so consumers can process them in emission order.
467
+ *
468
+ * The returned `nextId` is the maximum id seen (or `sinceId` when no rows
469
+ * match), suitable as the next `sinceId` cursor value.
470
+ */
471
+ export function readStateEvents(db, options = {}) {
472
+ const conditions = [];
473
+ const params = [];
474
+ if (options.sinceId !== undefined && options.sinceId > 0) {
475
+ conditions.push("id > ?");
476
+ params.push(options.sinceId);
477
+ }
478
+ if (options.since) {
479
+ conditions.push("ts >= ?");
480
+ params.push(options.since);
481
+ }
482
+ if (options.type) {
483
+ conditions.push("event_type = ?");
484
+ params.push(options.type);
485
+ }
486
+ if (options.ref) {
487
+ conditions.push("ref = ?");
488
+ params.push(options.ref);
489
+ }
490
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
491
+ const rows = db
492
+ .prepare(`SELECT id, event_type, ts, ref, metadata_json FROM events ${where} ORDER BY id ASC`)
493
+ .all(...params);
494
+ const events = rows.map(eventRowToEnvelope);
495
+ const nextId = events.length > 0 ? events[events.length - 1].id : (options.sinceId ?? 0);
496
+ return { events, nextId };
497
+ }
498
+ /**
499
+ * Delete events older than `retentionDays` (default: 90). Safe to call from
500
+ * a maintenance cron; uses a single DELETE with an index-covered ts predicate.
501
+ */
502
+ export function purgeOldEvents(db, retentionDays = 90) {
503
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0)
504
+ return;
505
+ const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
506
+ db.prepare("DELETE FROM events WHERE ts < ?").run(cutoff);
507
+ }
508
+ // ── proposals table helpers ──────────────────────────────────────────────────
509
+ /**
510
+ * Upsert a proposal row. Called by the proposal write path when state.db is
511
+ * the active backend.
512
+ */
513
+ export function upsertProposal(db, proposal, stashDir) {
514
+ const v = proposalToRowValues(proposal, stashDir);
515
+ db.prepare(`
516
+ INSERT INTO proposals
517
+ (id, stash_dir, ref, status, source, created_at, updated_at, content, frontmatter_json, metadata_json)
518
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
519
+ ON CONFLICT(id) DO UPDATE SET
520
+ stash_dir = excluded.stash_dir,
521
+ ref = excluded.ref,
522
+ status = excluded.status,
523
+ source = excluded.source,
524
+ updated_at = excluded.updated_at,
525
+ content = excluded.content,
526
+ frontmatter_json = excluded.frontmatter_json,
527
+ metadata_json = excluded.metadata_json
528
+ `).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);
529
+ }
530
+ /**
531
+ * List proposals, optionally filtered by stashDir, status, and/or ref.
532
+ * Results are sorted by created_at ASC to match the existing listProposals() behaviour.
533
+ */
534
+ export function listStateProposals(db, options = {}) {
535
+ const conditions = [];
536
+ const params = [];
537
+ if (options.stashDir) {
538
+ conditions.push("stash_dir = ?");
539
+ params.push(options.stashDir);
540
+ }
541
+ if (options.status) {
542
+ conditions.push("status = ?");
543
+ params.push(options.status);
544
+ }
545
+ if (options.ref) {
546
+ conditions.push("ref = ?");
547
+ params.push(options.ref);
548
+ }
549
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
550
+ const rows = db
551
+ .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
552
+ content, frontmatter_json, metadata_json
553
+ FROM proposals ${where} ORDER BY created_at ASC`)
554
+ .all(...params);
555
+ return rows.map(proposalRowToProposal);
556
+ }
557
+ /**
558
+ * Look up a single proposal by id. Returns undefined when not found.
559
+ */
560
+ export function getStateProposal(db, id) {
561
+ const row = db
562
+ .prepare(`SELECT id, stash_dir, ref, status, source, created_at, updated_at,
563
+ content, frontmatter_json, metadata_json
564
+ FROM proposals WHERE id = ?`)
565
+ .get(id);
566
+ return row ? proposalRowToProposal(row) : undefined;
567
+ }
568
+ // ── task_history table helpers ───────────────────────────────────────────────
569
+ /**
570
+ * Upsert a task history row.
571
+ */
572
+ export function upsertTaskHistory(db, row) {
573
+ // INSERT OR IGNORE: if a run with the same (task_id, started_at) was already
574
+ // imported (e.g. by the migration script), skip it silently.
575
+ db.prepare(`
576
+ INSERT OR IGNORE INTO task_history
577
+ (task_id, status, started_at, completed_at, failed_at, log_path,
578
+ target_kind, target_ref, metadata_json)
579
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
580
+ `).run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json);
581
+ }
582
+ /**
583
+ * Look up a task history row by task_id. Returns undefined when not found.
584
+ */
585
+ /**
586
+ * Return the most recent run for a given task_id, or undefined if no runs exist.
587
+ */
588
+ export function getTaskHistory(db, taskId) {
589
+ return db
590
+ .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
591
+ target_kind, target_ref, metadata_json
592
+ FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`)
593
+ .get(taskId);
594
+ }
595
+ /**
596
+ * Return all runs for a given task_id, newest first.
597
+ */
598
+ export function getTaskHistoryRuns(db, taskId, limit = 50) {
599
+ return db
600
+ .prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
601
+ target_kind, target_ref, metadata_json
602
+ FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`)
603
+ .all(taskId, limit);
604
+ }
605
+ /**
606
+ * Query task history rows by started_at range and/or status.
607
+ */
608
+ export function queryTaskHistory(db, options = {}) {
609
+ const conditions = [];
610
+ const params = [];
611
+ if (options.since) {
612
+ conditions.push("started_at >= ?");
613
+ params.push(options.since);
614
+ }
615
+ if (options.until) {
616
+ conditions.push("started_at <= ?");
617
+ params.push(options.until);
618
+ }
619
+ if (options.status) {
620
+ conditions.push("status = ?");
621
+ params.push(options.status);
622
+ }
623
+ if (options.targetKind) {
624
+ conditions.push("target_kind = ?");
625
+ params.push(options.targetKind);
626
+ }
627
+ if (options.targetRef) {
628
+ conditions.push("target_ref = ?");
629
+ params.push(options.targetRef);
630
+ }
631
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
632
+ return db
633
+ .prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
634
+ target_kind, target_ref, metadata_json
635
+ FROM task_history ${where} ORDER BY started_at DESC`)
636
+ .all(...params);
637
+ }
638
+ // ── events.jsonl import ──────────────────────────────────────────────────────
639
+ /**
640
+ * Import all events from an `events.jsonl` file into the `events` table.
641
+ *
642
+ * The old byte-offset `id` is NOT preserved — the database assigns new
643
+ * monotonic integer ids. Callers that persisted a byte-offset cursor must
644
+ * discard it after migration and use the returned `maxId` as the new cursor.
645
+ *
646
+ * The import is wrapped in a single transaction for atomicity. If the file
647
+ * has already been imported (the events table is non-empty and the file
648
+ * has not changed since last import), callers should skip calling this
649
+ * function — de-duplication is NOT performed here to keep the hot path fast.
650
+ *
651
+ * @param db - Open state.db connection.
652
+ * @param jsonlPath - Absolute path to the events.jsonl file to import.
653
+ * @returns Number of rows inserted and the max id assigned.
654
+ */
655
+ export async function importEventsJsonl(db, jsonlPath) {
656
+ const { readFileSync, existsSync } = await import("node:fs");
657
+ if (!existsSync(jsonlPath)) {
658
+ return { imported: 0, maxId: 0 };
659
+ }
660
+ const text = readFileSync(jsonlPath, "utf8");
661
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
662
+ let imported = 0;
663
+ let maxId = 0;
664
+ const stmt = db.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
665
+ VALUES (?, ?, ?, ?)
666
+ RETURNING id`);
667
+ db.transaction(() => {
668
+ for (const line of lines) {
669
+ let parsed;
670
+ try {
671
+ parsed = JSON.parse(line);
672
+ }
673
+ catch {
674
+ continue; // skip malformed lines — same behaviour as readEvents()
675
+ }
676
+ const eventType = typeof parsed.eventType === "string" ? parsed.eventType : "unknown";
677
+ const ts = typeof parsed.ts === "string" ? parsed.ts : new Date().toISOString();
678
+ const ref = typeof parsed.ref === "string" ? parsed.ref : null;
679
+ const metadata = parsed.metadata !== undefined && typeof parsed.metadata === "object" ? JSON.stringify(parsed.metadata) : "{}";
680
+ const result = stmt.get(eventType, ts, ref, metadata);
681
+ if (result) {
682
+ imported++;
683
+ if (result.id > maxId)
684
+ maxId = result.id;
685
+ }
686
+ }
687
+ })();
688
+ return { imported, maxId };
689
+ }
690
+ // ── registry_index_cache (goes in index.db, not state.db) ───────────────────
691
+ /**
692
+ * DDL for the `registry_index_cache` table that lives in the EXISTING index.db
693
+ * (managed by src/indexer/db.ts).
694
+ *
695
+ * Design: uses the same migration-safe ADD COLUMN approach. The table is
696
+ * created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
697
+ * ensureSchema() or as a standalone migration.
698
+ *
699
+ * Purpose: caches the result of resolving and fetching remote registry kit
700
+ * indexes so `akm search` does not hit the network on every invocation.
701
+ *
702
+ * Indexed (query) columns:
703
+ * registry_url TEXT PK — canonical URL of the registry; cache key.
704
+ * fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
705
+ * etag TEXT — HTTP ETag for conditional GET (If-None-Match).
706
+ * last_modified TEXT — HTTP Last-Modified for conditional GET.
707
+ *
708
+ * Non-indexed payload:
709
+ * index_json TEXT — JSON blob of the fetched registry index document.
710
+ *
711
+ * ADD COLUMN extension points (future migrations in db.ts):
712
+ * ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
713
+ * ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
714
+ * ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
715
+ *
716
+ * To add this table to index.db, call ensureRegistryIndexCacheSchema(db) from
717
+ * within ensureSchema() in src/indexer/db.ts, or add it as a new CREATE TABLE
718
+ * IF NOT EXISTS block inside the existing ensureSchema() call.
719
+ */
720
+ export const REGISTRY_INDEX_CACHE_DDL = `
721
+ CREATE TABLE IF NOT EXISTS registry_index_cache (
722
+ registry_url TEXT PRIMARY KEY,
723
+ fetched_at TEXT NOT NULL,
724
+ etag TEXT,
725
+ last_modified TEXT,
726
+ index_json TEXT NOT NULL DEFAULT '{}'
727
+ );
728
+
729
+ CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
730
+ ON registry_index_cache(fetched_at);
731
+ `;