akm-cli 0.9.0-beta.2 → 0.9.0-beta.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 (36) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/assets/templates/html/default.html +78 -0
  3. package/dist/assets/templates/html/health.html +560 -0
  4. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  5. package/dist/cli/shared.js +21 -5
  6. package/dist/cli.js +36 -5
  7. package/dist/commands/health/html-report.js +448 -0
  8. package/dist/commands/health.js +97 -6
  9. package/dist/commands/improve/extract.js +38 -2
  10. package/dist/commands/improve/improve-auto-accept.js +27 -1
  11. package/dist/commands/improve/improve.js +167 -53
  12. package/dist/commands/improve/reflect-noise.js +0 -0
  13. package/dist/commands/improve/reflect.js +25 -0
  14. package/dist/commands/proposal/drain.js +73 -6
  15. package/dist/commands/proposal/proposal-cli.js +22 -10
  16. package/dist/commands/proposal/proposal.js +12 -1
  17. package/dist/commands/proposal/validators/proposals.js +361 -338
  18. package/dist/commands/remember.js +6 -2
  19. package/dist/core/config/config-schema.js +5 -0
  20. package/dist/core/logs-db.js +304 -0
  21. package/dist/core/state-db.js +107 -14
  22. package/dist/indexer/db/db.js +2 -2
  23. package/dist/indexer/passes/memory-inference.js +61 -22
  24. package/dist/integrations/harnesses/claude/session-log.js +16 -4
  25. package/dist/llm/client.js +15 -0
  26. package/dist/llm/usage-persist.js +77 -0
  27. package/dist/llm/usage-telemetry.js +103 -0
  28. package/dist/output/context.js +3 -2
  29. package/dist/output/html-render.js +73 -0
  30. package/dist/output/shapes/helpers.js +17 -1
  31. package/dist/output/text/helpers.js +69 -1
  32. package/dist/scripts/migrate-storage.js +65 -14
  33. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +14 -2
  34. package/dist/tasks/runner.js +99 -16
  35. package/dist/workflows/db.js +4 -0
  36. package/package.json +1 -1
@@ -2,39 +2,46 @@
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
4
  /**
5
- * Proposal substrate (#225).
5
+ * Proposal substrate (#225, storage consolidated in #578).
6
6
  *
7
7
  * One durable proposal store for every future reflection / generation flow
8
8
  * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
9
- * Proposals are *queue state*, not source-of-truth assets — they sit on disk
10
- * waiting for human (or automated) review and only become assets after
9
+ * Proposals are *queue state*, not source-of-truth assets — they sit in the
10
+ * queue waiting for human (or automated) review and only become assets after
11
11
  * `akm proposal accept` validates and promotes them via
12
12
  * {@link writeAssetToSource}.
13
13
  *
14
- * # Storage layout
14
+ * # Storage
15
15
  *
16
- * <stashRoot>/.akm/proposals/<id>/proposal.json
17
- * <stashRoot>/.akm/proposals/archive/<id>/proposal.json
16
+ * The canonical store is the `proposals` table in `state.db` (SQLite, WAL
17
+ * mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
18
+ * multi-stash installs keep independent queues, and the `status` column
19
+ * distinguishes the live queue (`pending`) from the archive (`accepted` /
20
+ * `rejected` / `reverted`). There is no separate archive location — archival
21
+ * is a status flip, and the full audit trail (review outcome, reason, backup
22
+ * content for revert) lives on the row.
18
23
  *
19
- * One directory per proposal id (a stable `crypto.randomUUID()`), so multiple
20
- * proposals can target the same `ref` without filesystem collisions.
24
+ * ## Legacy filesystem import
21
25
  *
22
- * # Why direct fs (and not `writeAssetToSource`)
26
+ * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
+ * `<stashDir>/.akm/proposals/` (live) and `…/proposals/archive/` (archived).
28
+ * The first proposal operation against a stash imports any legacy
29
+ * `proposal.json` files into the table (INSERT OR IGNORE keyed on the UUID,
30
+ * so re-runs never duplicate) and records the stash in `proposal_fs_imports`
31
+ * so later invocations skip the directory walk. The legacy files are left in
32
+ * place untouched — they are inert after import and may be removed by the
33
+ * operator at leisure.
23
34
  *
24
- * The architectural rule "all writes go through `writeAssetToSource`" applies
25
- * to *assets*. Proposals are **not** assets — they live outside the asset tree
26
- * (under `.akm/proposals/`, parallel to how `events.jsonl` lives outside the
27
- * asset tree). Routing them through `writeAssetToSource` would force them into
28
- * a `TYPE_DIRS` slot, would commit them to git, and would leak unaccepted
29
- * drafts through the normal indexer. None of that is what we want for queue
30
- * state. The {@link promoteProposal} step is the bridge: it routes the
31
- * accepted payload through `writeAssetToSource` so the actual asset write
32
- * still funnels through the single dispatch point in
33
- * `src/core/write-source.ts`.
35
+ * # Why the queue bypasses `writeAssetToSource`
34
36
  *
35
- * Direct `fs` IO here is deliberate and the only place in the v1 codebase
36
- * that bypasses `writeAssetToSource` for "stash-adjacent" durable state. See
37
- * CLAUDE.md ("Writes" section) for the contract.
37
+ * The architectural rule "all writes go through `writeAssetToSource`" applies
38
+ * to *assets*. Proposals are **not** assets they live outside the asset
39
+ * tree (in state.db, parallel to how events do). Routing them through
40
+ * `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
41
+ * them to git, and would leak unaccepted drafts through the normal indexer.
42
+ * The {@link promoteProposal} step is the bridge: it routes the accepted
43
+ * payload through `writeAssetToSource` so the actual asset write still
44
+ * funnels through the single dispatch point in `src/core/write-source.ts`.
38
45
  */
39
46
  import { createHash, randomUUID } from "node:crypto";
40
47
  import fs from "node:fs";
@@ -43,6 +50,7 @@ import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
43
50
  import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
44
51
  import { NotFoundError, UsageError } from "../../../core/errors.js";
45
52
  import { appendEvent } from "../../../core/events.js";
53
+ import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, } from "../../../core/state-db.js";
46
54
  import { warn } from "../../../core/warn.js";
47
55
  import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
48
56
  import { runProposalValidators } from "./proposal-validators.js";
@@ -131,21 +139,7 @@ function cooldownMsForSource(source) {
131
139
  function contentHash(content) {
132
140
  return createHash("sha256").update(content, "utf8").digest("hex");
133
141
  }
134
- // ── Path helpers ────────────────────────────────────────────────────────────
135
- /**
136
- * Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
137
- * fs paths because proposal storage is queue state, not asset state — see the
138
- * module docblock for the architectural carve-out.
139
- */
140
- export function getProposalsRoot(stashDir, archive = false) {
141
- return archive ? path.join(stashDir, ".akm", "proposals", "archive") : path.join(stashDir, ".akm", "proposals");
142
- }
143
- function proposalDir(stashDir, id, archive) {
144
- return path.join(getProposalsRoot(stashDir, archive), id);
145
- }
146
- function proposalFile(stashDir, id, archive) {
147
- return path.join(proposalDir(stashDir, id, archive), "proposal.json");
148
- }
142
+ // ── Store access ─────────────────────────────────────────────────────────────
149
143
  function nowIso(ctx) {
150
144
  const fn = ctx?.now ?? Date.now;
151
145
  return new Date(fn()).toISOString();
@@ -154,35 +148,124 @@ function newId(ctx) {
154
148
  const fn = ctx?.randomUUID ?? randomUUID;
155
149
  return fn();
156
150
  }
157
- // ── Read / write primitives ─────────────────────────────────────────────────
158
- function readProposalFile(filePath) {
159
- let raw;
151
+ /**
152
+ * Open the state database (honouring the `ctx.dbPath` test seam), run the
153
+ * legacy filesystem import for `stashDir` if it has not happened yet, hand the
154
+ * connection to `fn`, and close it in a `finally`. Every public function in
155
+ * this module funnels its store access through here so the legacy import is
156
+ * guaranteed to have run before any read or write.
157
+ */
158
+ function withProposalsDb(stashDir, ctx, fn) {
159
+ const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
160
160
  try {
161
- raw = fs.readFileSync(filePath, "utf8");
161
+ importLegacyProposalFiles(db, stashDir);
162
+ return fn(db);
162
163
  }
163
- catch (err) {
164
- throw new NotFoundError(`Proposal not found at ${filePath}.`, "FILE_NOT_FOUND", `The proposal file is missing or unreadable: ${err.message}`);
164
+ finally {
165
+ db.close();
165
166
  }
167
+ }
168
+ // ── Legacy filesystem import (#578) ─────────────────────────────────────────
169
+ /** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
170
+ function legacyProposalsRoot(stashDir, archive) {
171
+ const root = path.join(stashDir, ".akm", "proposals");
172
+ return archive ? path.join(root, "archive") : root;
173
+ }
174
+ /**
175
+ * One-shot import of legacy `proposal.json` files into the `proposals` table.
176
+ *
177
+ * Idempotent at two levels: the `proposal_fs_imports` ledger skips the
178
+ * directory walk after the first successful import, and INSERT OR IGNORE
179
+ * (keyed on the proposal UUID) protects against duplicates even if the walk
180
+ * re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
181
+ * `akm proposal revert` keeps working for proposals accepted before 0.9.0.
182
+ *
183
+ * The legacy files are never modified or deleted — after import they are
184
+ * inert artifacts the operator can remove at leisure.
185
+ */
186
+ function importLegacyProposalFiles(db, stashDir) {
187
+ if (hasImportedFsProposals(db, stashDir))
188
+ return;
189
+ const liveRoot = legacyProposalsRoot(stashDir, false);
190
+ if (!fs.existsSync(liveRoot))
191
+ return;
192
+ let imported = 0;
193
+ for (const archive of [false, true]) {
194
+ const root = legacyProposalsRoot(stashDir, archive);
195
+ let entries;
196
+ try {
197
+ entries = fs.readdirSync(root, { withFileTypes: true });
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ for (const entry of entries) {
203
+ if (!entry.isDirectory() || entry.name === "archive")
204
+ continue;
205
+ const proposalDir = path.join(root, entry.name);
206
+ const proposal = readLegacyProposalFile(proposalDir);
207
+ if (!proposal)
208
+ continue;
209
+ if (insertProposalIfAbsent(db, proposal, stashDir))
210
+ imported += 1;
211
+ }
212
+ }
213
+ recordFsProposalsImport(db, stashDir, imported);
214
+ if (imported > 0) {
215
+ warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
216
+ }
217
+ }
218
+ /**
219
+ * Parse one legacy proposal directory into a {@link Proposal}, inlining the
220
+ * backup file (when present) as `backupContent`. Returns undefined — with a
221
+ * warning — when the `proposal.json` is missing, unreadable, or malformed, so
222
+ * a single corrupt legacy entry never blocks the import of the rest.
223
+ */
224
+ function readLegacyProposalFile(proposalDir) {
225
+ const filePath = path.join(proposalDir, "proposal.json");
166
226
  let parsed;
167
227
  try {
168
- parsed = JSON.parse(raw);
228
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
169
229
  }
170
230
  catch (err) {
171
- throw new UsageError(`Proposal file at ${filePath} is not valid JSON: ${err.message}`, "INVALID_JSON_ARGUMENT", "Re-create the proposal or remove the corrupt file under .akm/proposals/<id>/.");
231
+ warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
232
+ return undefined;
172
233
  }
173
- if (typeof parsed !== "object" || parsed === null) {
174
- throw new UsageError(`Proposal file at ${filePath} is not a JSON object.`, "INVALID_JSON_ARGUMENT");
234
+ if (typeof parsed !== "object" ||
235
+ parsed === null ||
236
+ typeof parsed.id !== "string" ||
237
+ typeof parsed.ref !== "string") {
238
+ warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
239
+ return undefined;
175
240
  }
176
- return parsed;
177
- }
178
- function writeProposalFile(filePath, proposal) {
179
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
180
- fs.writeFileSync(filePath, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
241
+ const { backup, ...rest } = parsed;
242
+ let backupContent;
243
+ if (typeof backup === "string" && backup.length > 0) {
244
+ try {
245
+ backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
246
+ }
247
+ catch {
248
+ // Backup file lost — import the proposal anyway; revert for it will
249
+ // surface "no backup available", same as a new-asset proposal.
250
+ }
251
+ }
252
+ return {
253
+ ...rest,
254
+ payload: {
255
+ content: rest.payload?.content ?? "",
256
+ ...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
257
+ },
258
+ createdAt: rest.createdAt ?? "",
259
+ updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
260
+ status: rest.status ?? "pending",
261
+ source: rest.source ?? "import",
262
+ ...(backupContent !== undefined ? { backupContent } : {}),
263
+ };
181
264
  }
182
265
  // ── Public API ──────────────────────────────────────────────────────────────
183
266
  /**
184
267
  * Create a new pending proposal. The id is a stable random UUID, so two
185
- * proposals with the same `ref` never collide on disk.
268
+ * proposals with the same `ref` never collide.
186
269
  *
187
270
  * **Dedup / cooldown guard** (F-2 / #363):
188
271
  *
@@ -196,7 +279,7 @@ function writeProposalFile(filePath, proposal) {
196
279
  * others: 7 d). Bypass with `force: true`.
197
280
  *
198
281
  * When a guard fires the function returns a `CreateProposalSkipped` record
199
- * instead of writing to disk. Use {@link isProposalSkipped} to detect it.
282
+ * instead of writing. Use {@link isProposalSkipped} to detect it.
200
283
  */
201
284
  export function createProposal(stashDir, input, ctx) {
202
285
  // F-4 / #385: Validate source against the allow-list. Unknown values are
@@ -250,173 +333,144 @@ export function createProposal(stashDir, input, ctx) {
250
333
  }
251
334
  }
252
335
  const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
253
- if (!input.force) {
254
- const newHash = contentHash(input.payload.content);
255
- const nowMs = (ctx?.now ?? Date.now)();
256
- const cooldownMs = cooldownMsForSource(input.source);
257
- // Scan pending proposals for ref+source matches.
258
- const pending = listProposals(stashDir, { ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
259
- if (pending.length > 0) {
260
- // Check for identical content hash first (silent skip).
261
- const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
262
- if (hashMatch) {
263
- return {
264
- skipped: true,
265
- reason: "content_hash_match",
266
- message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
267
- existingProposalId: hashMatch.id,
268
- };
269
- }
270
- // Duplicate pending for same ref+source (different content).
271
- const firstPending = pending[0];
336
+ return withProposalsDb(stashDir, ctx, (db) => {
337
+ if (!input.force) {
338
+ const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
339
+ if (skip)
340
+ return skip;
341
+ }
342
+ const created = nowIso(ctx);
343
+ // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
344
+ // is dropped silently we never store NaN, Infinity, or out-of-range values.
345
+ // Callers that mis-report confidence should not poison the auto-accept gate.
346
+ const sanitizedConfidence = typeof input.confidence === "number" &&
347
+ Number.isFinite(input.confidence) &&
348
+ input.confidence >= 0 &&
349
+ input.confidence <= 1
350
+ ? input.confidence
351
+ : undefined;
352
+ const proposal = {
353
+ id: newId(ctx),
354
+ ref: normalizedRef,
355
+ status: "pending",
356
+ source: input.source,
357
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
358
+ createdAt: created,
359
+ updatedAt: created,
360
+ payload: {
361
+ content: input.payload.content,
362
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
363
+ },
364
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
365
+ };
366
+ upsertProposal(db, proposal, stashDir);
367
+ return proposal;
368
+ });
369
+ }
370
+ /**
371
+ * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
372
+ * skip record when a guard fires, or undefined when the create may proceed.
373
+ */
374
+ function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
375
+ const newHash = contentHash(input.payload.content);
376
+ const nowMs = (ctx?.now ?? Date.now)();
377
+ const cooldownMs = cooldownMsForSource(input.source);
378
+ // Scan pending proposals for ref+source matches.
379
+ const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
380
+ if (pending.length > 0) {
381
+ // Check for identical content hash first (silent skip).
382
+ const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
383
+ if (hashMatch) {
272
384
  return {
273
385
  skipped: true,
274
- reason: "duplicate_pending",
275
- message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
276
- existingProposalId: firstPending?.id,
386
+ reason: "content_hash_match",
387
+ message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
388
+ existingProposalId: hashMatch.id,
277
389
  };
278
390
  }
279
- // Check cooldown against recently archived rejected proposals.
280
- const rejected = listProposals(stashDir, { ref: normalizedRef, status: "rejected", includeArchive: true })
281
- .filter((p) => p.source === input.source)
282
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
283
- if (rejected.length > 0 && rejected[0] !== undefined) {
284
- const mostRecent = rejected[0];
285
- // Check content hash against recently rejected.
286
- if (contentHash(mostRecent.payload.content) === newHash) {
287
- return {
288
- skipped: true,
289
- reason: "content_hash_match",
290
- message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
291
- existingProposalId: mostRecent.id,
292
- };
293
- }
294
- // Check cooldown window.
295
- const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
296
- if (nowMs - rejectedAt < cooldownMs) {
297
- const cooldownDays = cooldownMs / MS_PER_DAY;
298
- const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
299
- return {
300
- skipped: true,
301
- reason: "cooldown",
302
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
303
- `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
304
- existingProposalId: mostRecent.id,
305
- };
306
- }
391
+ // Duplicate pending for same ref+source (different content).
392
+ const firstPending = pending[0];
393
+ return {
394
+ skipped: true,
395
+ reason: "duplicate_pending",
396
+ message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
397
+ existingProposalId: firstPending?.id,
398
+ };
399
+ }
400
+ // Check cooldown against recently rejected proposals.
401
+ const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
402
+ .filter((p) => p.source === input.source)
403
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
404
+ const mostRecent = rejected[0];
405
+ if (mostRecent !== undefined) {
406
+ // Check content hash against recently rejected.
407
+ if (contentHash(mostRecent.payload.content) === newHash) {
408
+ return {
409
+ skipped: true,
410
+ reason: "content_hash_match",
411
+ message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
412
+ existingProposalId: mostRecent.id,
413
+ };
414
+ }
415
+ // Check cooldown window.
416
+ const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
417
+ if (nowMs - rejectedAt < cooldownMs) {
418
+ const cooldownDays = cooldownMs / MS_PER_DAY;
419
+ const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
420
+ return {
421
+ skipped: true,
422
+ reason: "cooldown",
423
+ message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
424
+ `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
425
+ existingProposalId: mostRecent.id,
426
+ };
307
427
  }
308
428
  }
309
- const id = newId(ctx);
310
- const created = nowIso(ctx);
311
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
312
- // is dropped silently — we never store NaN, Infinity, or out-of-range values.
313
- // Callers that mis-report confidence should not poison the auto-accept gate.
314
- const sanitizedConfidence = typeof input.confidence === "number" &&
315
- Number.isFinite(input.confidence) &&
316
- input.confidence >= 0 &&
317
- input.confidence <= 1
318
- ? input.confidence
319
- : undefined;
320
- const proposal = {
321
- id,
322
- ref: normalizedRef,
323
- status: "pending",
324
- source: input.source,
325
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
326
- createdAt: created,
327
- updatedAt: created,
328
- payload: {
329
- content: input.payload.content,
330
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
331
- },
332
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
333
- };
334
- writeProposalFile(proposalFile(stashDir, id, false), proposal);
335
- return proposal;
429
+ return undefined;
336
430
  }
337
431
  /**
338
- * List every proposal under the stash. By default returns pending proposals
339
- * from the live queue; pass `{ includeArchive: true }` to include rejected /
340
- * accepted entries that have been moved aside.
432
+ * List proposals for one stash. By default returns only the live (pending)
433
+ * queue; pass `{ includeArchive: true }` to include accepted / rejected /
434
+ * reverted entries as well.
341
435
  */
342
- export function listProposals(stashDir, options = {}) {
343
- const out = [];
344
- const roots = [{ dir: getProposalsRoot(stashDir, false), archive: false }];
345
- if (options.includeArchive) {
346
- roots.push({ dir: getProposalsRoot(stashDir, true), archive: true });
347
- }
348
- for (const { dir } of roots) {
349
- if (!fs.existsSync(dir))
350
- continue;
351
- let entries;
352
- try {
353
- entries = fs.readdirSync(dir, { withFileTypes: true });
354
- }
355
- catch {
356
- continue;
436
+ export function listProposals(stashDir, options = {}, ctx) {
437
+ return withProposalsDb(stashDir, ctx, (db) => {
438
+ // Without includeArchive, only the live queue is visible — an explicit
439
+ // non-pending status filter therefore matches nothing (mirrors the
440
+ // historical live-directory scan).
441
+ if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
442
+ return [];
357
443
  }
358
- for (const entry of entries) {
359
- // Skip the archive subdirectory when iterating the live queue.
360
- if (!entry.isDirectory())
361
- continue;
362
- if (entry.name === "archive")
363
- continue;
364
- const filePath = path.join(dir, entry.name, "proposal.json");
365
- if (!fs.existsSync(filePath))
366
- continue;
444
+ const status = options.includeArchive ? options.status : "pending";
445
+ return listStateProposals(db, {
446
+ stashDir,
447
+ ...(status !== undefined ? { status } : {}),
448
+ ...(options.ref !== undefined ? { ref: options.ref } : {}),
449
+ }).filter((p) => {
450
+ if (!options.type)
451
+ return true;
367
452
  try {
368
- out.push(readProposalFile(filePath));
453
+ return parseAssetRef(p.ref).type === options.type;
369
454
  }
370
455
  catch {
371
- // Surface invalid proposal files via a synthetic stub so callers can
372
- // see something in `akm proposal list` rather than the file
373
- // disappearing silently.
374
- out.push({
375
- id: entry.name,
376
- ref: "unknown:unknown",
377
- status: "rejected",
378
- source: "invalid",
379
- createdAt: "",
380
- updatedAt: "",
381
- payload: { content: "" },
382
- review: {
383
- outcome: "rejected",
384
- reason: "Invalid proposal file (could not be parsed).",
385
- decidedAt: "",
386
- },
387
- });
456
+ return false;
388
457
  }
389
- }
390
- }
391
- return out
392
- .filter((p) => (options.status ? p.status === options.status : true))
393
- .filter((p) => (options.ref ? p.ref === options.ref : true))
394
- .filter((p) => {
395
- if (!options.type)
396
- return true;
397
- try {
398
- return parseAssetRef(p.ref).type === options.type;
399
- }
400
- catch {
401
- // Unparseable ref (e.g. the synthetic "unknown:unknown" stub for an
402
- // invalid proposal file) never matches a concrete type filter.
403
- return false;
404
- }
405
- })
406
- .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
458
+ });
459
+ });
407
460
  }
408
461
  /**
409
- * Look up a proposal by id. Searches the live queue first, then the archive.
410
- * Throws `NotFoundError` when no match exists.
462
+ * Look up a proposal by id (live or archived).
463
+ * Throws `NotFoundError` when no match exists in this stash.
411
464
  */
412
- export function getProposal(stashDir, id) {
413
- const livePath = proposalFile(stashDir, id, false);
414
- if (fs.existsSync(livePath))
415
- return readProposalFile(livePath);
416
- const archivedPath = proposalFile(stashDir, id, true);
417
- if (fs.existsSync(archivedPath))
418
- return readProposalFile(archivedPath);
419
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
465
+ export function getProposal(stashDir, id, ctx) {
466
+ return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
467
+ }
468
+ function requireProposal(db, stashDir, id) {
469
+ const proposal = getStateProposal(db, id, stashDir);
470
+ if (!proposal) {
471
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
472
+ }
473
+ return proposal;
420
474
  }
421
475
  /**
422
476
  * Resolve a proposal by full UUID, UUID prefix, or asset ref.
@@ -425,95 +479,85 @@ export function getProposal(stashDir, id) {
425
479
  * 1. Exact UUID match (existing behaviour).
426
480
  * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
427
481
  * that ref; falls back to archived if nothing is pending.
428
- * 3. UUID prefix — matches any live proposal directory whose name starts
429
- * with the given string; throws if ambiguous.
482
+ * 3. UUID prefix — matches any PENDING proposal whose id starts with the
483
+ * given string; throws if ambiguous.
430
484
  */
431
- export function resolveProposalId(stashDir, idOrRef) {
432
- // 1. Exact UUID.
433
- try {
434
- return getProposal(stashDir, idOrRef);
435
- }
436
- catch (e) {
437
- if (!(e instanceof NotFoundError))
438
- throw e;
439
- }
440
- // 2. Asset ref (e.g. "skill:akm-dream").
441
- if (idOrRef.includes(":")) {
442
- const pending = listProposals(stashDir, { ref: idOrRef });
443
- if (pending.length > 0) {
444
- return pending.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
485
+ export function resolveProposalId(stashDir, idOrRef, ctx) {
486
+ return withProposalsDb(stashDir, ctx, (db) => {
487
+ // 1. Exact UUID.
488
+ const exact = getStateProposal(db, idOrRef, stashDir);
489
+ if (exact)
490
+ return exact;
491
+ // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
492
+ // recent archived.
493
+ if (idOrRef.includes(":")) {
494
+ const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
495
+ const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
496
+ if (pending)
497
+ return pending;
498
+ const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
499
+ if (archived)
500
+ return archived;
501
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
445
502
  }
446
- const archived = listProposals(stashDir, { ref: idOrRef, includeArchive: true });
447
- if (archived.length > 0) {
448
- return archived.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
503
+ // 3. UUID prefix (pending queue only).
504
+ const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
505
+ if (prefixMatches.length === 1)
506
+ return requireProposal(db, stashDir, prefixMatches[0]);
507
+ if (prefixMatches.length > 1) {
508
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
449
509
  }
450
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
451
- }
452
- // 3. UUID prefix.
453
- const liveDir = getProposalsRoot(stashDir, false);
454
- let prefixMatches = [];
455
- try {
456
- prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
457
- }
458
- catch {
459
- /* live dir may not exist yet */
460
- }
461
- if (prefixMatches.length === 1)
462
- return getProposal(stashDir, prefixMatches[0]);
463
- if (prefixMatches.length > 1) {
464
- throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
465
- }
466
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
510
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
511
+ });
467
512
  }
468
513
  /**
469
- * Whether a proposal currently lives in the archive (used by callers that
470
- * need to know whether to look in the archive root for files / paths).
514
+ * Archive a proposal: flip its status to `accepted` / `rejected`, bump
515
+ * `updatedAt`, and record the review block. Used by both accept and reject
516
+ * paths so the live queue only contains pending entries.
471
517
  */
472
- export function isProposalArchived(stashDir, id) {
473
- return !fs.existsSync(proposalFile(stashDir, id, false)) && fs.existsSync(proposalFile(stashDir, id, true));
518
+ export function archiveProposal(stashDir, id, status, reason, ctx) {
519
+ return withProposalsDb(stashDir, ctx, (db) => {
520
+ const existing = requireProposal(db, stashDir, id);
521
+ const updated = {
522
+ ...existing,
523
+ status,
524
+ updatedAt: nowIso(ctx),
525
+ review: {
526
+ outcome: status,
527
+ ...(reason !== undefined ? { reason } : {}),
528
+ decidedAt: nowIso(ctx),
529
+ },
530
+ };
531
+ upsertProposal(db, updated, stashDir);
532
+ return updated;
533
+ });
474
534
  }
475
535
  /**
476
- * Move a proposal directory into the archive subtree and update its status.
477
- * Used by both accept (status `accepted`) and reject (status `rejected`)
478
- * paths so the live queue only contains pending entries.
536
+ * Record an automated gate's decision onto a proposal (#577).
537
+ *
538
+ * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
539
+ * row so `akm proposal show` and `list` can explain why a proposal landed where
540
+ * it did. The decision is metadata about the adjudication, so this does NOT
541
+ * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
542
+ * and the accept / reject status flips are owned by {@link promoteProposal} /
543
+ * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
544
+ *
545
+ * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
546
+ * skipped silently rather than throwing, so a gate run never aborts mid-batch.
547
+ * Returns the updated proposal, or undefined when no matching row exists.
479
548
  */
480
- export function archiveProposal(stashDir, id, status, reason, ctx) {
481
- const sourceDir = proposalDir(stashDir, id, false);
482
- if (!fs.existsSync(sourceDir)) {
483
- // If it's already archived, just update the metadata in place.
484
- const archived = proposalFile(stashDir, id, true);
485
- if (fs.existsSync(archived)) {
486
- const existing = readProposalFile(archived);
487
- const updated = {
488
- ...existing,
489
- status,
490
- updatedAt: nowIso(ctx),
491
- review: {
492
- outcome: status,
493
- ...(reason !== undefined ? { reason } : {}),
494
- decidedAt: nowIso(ctx),
495
- },
496
- };
497
- writeProposalFile(archived, updated);
498
- return updated;
499
- }
500
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
501
- }
502
- const targetDir = proposalDir(stashDir, id, true);
503
- fs.mkdirSync(path.dirname(targetDir), { recursive: true });
504
- fs.renameSync(sourceDir, targetDir);
505
- const updated = {
506
- ...readProposalFile(proposalFile(stashDir, id, true)),
507
- status,
508
- updatedAt: nowIso(ctx),
509
- review: {
510
- outcome: status,
511
- ...(reason !== undefined ? { reason } : {}),
512
- decidedAt: nowIso(ctx),
513
- },
514
- };
515
- writeProposalFile(proposalFile(stashDir, id, true), updated);
516
- return updated;
549
+ export function recordGateDecision(stashDir, id, decision, ctx) {
550
+ return withProposalsDb(stashDir, ctx, (db) => {
551
+ const existing = getStateProposal(db, id, stashDir);
552
+ if (!existing)
553
+ return undefined;
554
+ const updated = {
555
+ ...existing,
556
+ gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
557
+ };
558
+ upsertProposal(db, updated, stashDir);
559
+ return updated;
560
+ });
517
561
  }
518
562
  /**
519
563
  * Scan all pending proposals and reject those whose target asset no longer
@@ -529,7 +573,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
529
573
  const t0 = Date.now();
530
574
  const orphans = [];
531
575
  const byType = {};
532
- const pending = listProposals(stashDir, { status: "pending" });
576
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
533
577
  const reflectPending = pending.filter((p) => p.source === "reflect");
534
578
  for (const p of reflectPending) {
535
579
  let parsed;
@@ -608,7 +652,7 @@ export function expireStaleProposals(stashDir, config, ctx) {
608
652
  }
609
653
  const retentionMs = retentionDays * MS_PER_DAY;
610
654
  const nowMs = (ctx?.now ?? Date.now)();
611
- const pending = listProposals(stashDir, { status: "pending" });
655
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
612
656
  for (const p of pending) {
613
657
  const createdMs = new Date(p.createdAt).getTime();
614
658
  if (!Number.isFinite(createdMs))
@@ -658,18 +702,17 @@ export function validateProposal(proposal) {
658
702
  /**
659
703
  * Validate a proposal, then promote it through the canonical
660
704
  * {@link writeAssetToSource} dispatch (the single place that branches on
661
- * `source.kind`). On success the proposal directory is moved to the archive
662
- * with status `accepted`. Validation failures throw a `UsageError` carrying
663
- * every finding so the CLI can render a single clear error envelope.
705
+ * `source.kind`). On success the proposal is archived with status `accepted`.
706
+ * Validation failures throw a `UsageError` carrying every finding so the CLI
707
+ * can render a single clear error envelope.
664
708
  *
665
709
  * Phase 6C: when the target asset already exists at the resolved write path,
666
- * a snapshot of the prior content is captured under
667
- * `<proposalsRoot>/<id>/backup.<ext>` BEFORE the write. The relative path is
668
- * recorded on the proposal record (`backup` field) so `akm proposal revert`
669
- * can restore the prior content. Genuinely-new assets carry no backup.
710
+ * its prior content is captured BEFORE the write and stored on the archived
711
+ * proposal record (`backupContent`) so `akm proposal revert` can restore it.
712
+ * Genuinely-new assets carry no backup.
670
713
  */
671
714
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
672
- const proposal = getProposal(stashDir, id);
715
+ const proposal = getProposal(stashDir, id, ctx);
673
716
  if (proposal.status !== "pending") {
674
717
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
675
718
  }
@@ -683,25 +726,15 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
683
726
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
684
727
  }
685
728
  const target = resolveWriteTarget(config, options.target);
686
- // Phase 6C: capture a backup of the prior content (if any) BEFORE writing the
687
- // new asset. We use the resolved write target to compute the exact path the
729
+ // Phase 6C: capture the prior content (if any) BEFORE writing the new
730
+ // asset. We use the resolved write target to compute the exact path the
688
731
  // asset would land at — same resolver `writeAssetToSource` uses — so the
689
732
  // backup always mirrors what would be overwritten.
690
- let backupRelPath;
733
+ let backupContent;
691
734
  try {
692
735
  const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
693
736
  if (targetFilePath && fs.existsSync(targetFilePath)) {
694
- const ext = path.extname(targetFilePath) || ".md";
695
- const proposalRoot = proposalDir(stashDir, id, false);
696
- // Store relative path on the proposal record so the directory remains
697
- // portable if the stash is moved.
698
- const backupFilename = `backup${ext}`;
699
- const backupAbsPath = path.join(proposalRoot, backupFilename);
700
- fs.mkdirSync(proposalRoot, { recursive: true });
701
- // Use copyFileSync — file-system atomicity is sufficient here because the
702
- // backup is single-file and never read concurrently with this write.
703
- fs.copyFileSync(targetFilePath, backupAbsPath);
704
- backupRelPath = backupFilename;
737
+ backupContent = fs.readFileSync(targetFilePath, "utf8");
705
738
  }
706
739
  }
707
740
  catch (err) {
@@ -715,64 +748,54 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
715
748
  // targets. No-op for filesystem/primary-stash targets.
716
749
  commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
717
750
  const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
718
- // Persist the backup path on the archived proposal record. archiveProposal
719
- // moves the proposal dir into the archive subtree, so the backup file moves
720
- // with it (the relative path stays valid).
721
- if (backupRelPath) {
722
- const archivedFile = proposalFile(stashDir, id, true);
723
- const withBackup = { ...archived, backup: backupRelPath };
724
- writeProposalFile(archivedFile, withBackup);
751
+ // Persist the backup content on the archived proposal record so the revert
752
+ // flow can restore the prior asset state.
753
+ if (backupContent !== undefined) {
754
+ const withBackup = { ...archived, backupContent };
755
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
725
756
  return { proposal: withBackup, assetPath: written.path, ref: written.ref };
726
757
  }
727
758
  return { proposal: archived, assetPath: written.path, ref: written.ref };
728
759
  }
729
760
  /**
730
- * Restore the prior content of an accepted proposal from its captured backup
731
- * (Advantage D6c / Phase 6C).
761
+ * Restore the prior content of an accepted proposal from the backup captured
762
+ * at promotion time (Advantage D6c / Phase 6C).
732
763
  *
733
764
  * Pre-conditions:
734
765
  * - `id` resolves to a proposal with `status === "accepted"`.
735
- * - The proposal carries a `backup` field pointing to a readable file under
736
- * the proposal directory.
766
+ * - The proposal carries `backupContent` (captured by promoteProposal when
767
+ * the target asset existed before the write).
737
768
  *
738
769
  * On success:
739
770
  * - The backup content is written back through {@link writeAssetToSource},
740
771
  * so the canonical write-dispatch invariant is preserved.
741
- * - The archived proposal record is updated to `status: "reverted"`.
772
+ * - The proposal record is updated to `status: "reverted"`.
742
773
  * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
743
774
  * `promoted` / `rejected` are emitted by the CLI command, not the core).
744
775
  *
745
776
  * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
746
- * cleanly to exit codes — see `src/commands/proposal.ts` for the wrapper.
777
+ * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
778
+ * wrapper.
747
779
  */
748
780
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
749
- const proposal = getProposal(stashDir, id);
781
+ const proposal = getProposal(stashDir, id, ctx);
750
782
  if (proposal.status !== "accepted") {
751
783
  throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
752
784
  }
753
- if (!proposal.backup) {
785
+ if (proposal.backupContent === undefined) {
754
786
  throw new UsageError(`no backup available for this proposal (id: ${id})`, "MISSING_REQUIRED_ARGUMENT", "Backups are only captured when a proposal overwrites an existing asset — new-asset proposals cannot be reverted via this path; delete the asset directly instead.");
755
787
  }
756
- // The proposal directory has been moved to the archive subtree (archiveProposal
757
- // runs at the end of promoteProposal). Reads must resolve against that path.
758
- const proposalRoot = proposalDir(stashDir, id, true);
759
- const backupAbsPath = path.join(proposalRoot, proposal.backup);
760
- if (!fs.existsSync(backupAbsPath)) {
761
- throw new NotFoundError(`no backup available for this proposal (id: ${id})`, "FILE_NOT_FOUND", `Expected backup file at ${backupAbsPath}; it may have been removed manually.`);
762
- }
763
- const backupContent = fs.readFileSync(backupAbsPath, "utf8");
764
788
  const ref = parseAssetRef(proposal.ref);
765
789
  if (!TYPE_DIRS[ref.type]) {
766
790
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
767
791
  }
768
792
  const target = resolveWriteTarget(config, options.target);
769
- const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
793
+ const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
770
794
  // 0.9.0 (issue #507): single batch commit at the write boundary for git
771
795
  // targets. No-op for filesystem/primary-stash targets.
772
796
  commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
773
- // Update the archived proposal record to status: "reverted" and bump
774
- // updatedAt + review so the audit trail reflects the second decision.
775
- const archivedFile = proposalFile(stashDir, id, true);
797
+ // Update the proposal record to status: "reverted" and bump updatedAt +
798
+ // review so the audit trail reflects the second decision.
776
799
  const now = nowIso(ctx);
777
800
  const reverted = {
778
801
  ...proposal,
@@ -784,7 +807,7 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
784
807
  decidedAt: now,
785
808
  },
786
809
  };
787
- writeProposalFile(archivedFile, reverted);
810
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
788
811
  return { proposal: reverted, assetPath: written.path, ref: written.ref };
789
812
  }
790
813
  /**
@@ -793,8 +816,8 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
793
816
  * diff matches exactly what `accept` will write. Falls back to "new asset"
794
817
  * when no asset is currently materialised at the target ref.
795
818
  */
796
- export function diffProposal(stashDir, config, id, options = {}) {
797
- const proposal = getProposal(stashDir, id);
819
+ export function diffProposal(stashDir, config, id, options = {}, ctx) {
820
+ const proposal = getProposal(stashDir, id, ctx);
798
821
  const ref = parseAssetRef(proposal.ref);
799
822
  let targetPath;
800
823
  let existing = null;