akm-cli 0.9.0-beta.2 → 0.9.0-beta.27

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 (120) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +2079 -608
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/fact-linter.js +39 -0
  55. package/dist/commands/lint/index.js +31 -13
  56. package/dist/commands/lint/memory-linter.js +1 -1
  57. package/dist/commands/lint/registry.js +7 -2
  58. package/dist/commands/lint/task-linter.js +3 -3
  59. package/dist/commands/lint/workflow-linter.js +26 -1
  60. package/dist/commands/proposal/drain.js +73 -6
  61. package/dist/commands/proposal/proposal-cli.js +22 -10
  62. package/dist/commands/proposal/proposal.js +17 -1
  63. package/dist/commands/proposal/validators/proposals.js +369 -329
  64. package/dist/commands/read/curate.js +344 -80
  65. package/dist/commands/read/search-cli.js +7 -0
  66. package/dist/commands/read/search.js +1 -0
  67. package/dist/commands/read/show.js +67 -2
  68. package/dist/commands/remember.js +6 -2
  69. package/dist/commands/sources/installed-stashes.js +5 -1
  70. package/dist/commands/sources/stash-cli.js +10 -2
  71. package/dist/core/asset/asset-registry.js +2 -0
  72. package/dist/core/asset/asset-spec.js +14 -0
  73. package/dist/core/asset/frontmatter.js +166 -167
  74. package/dist/core/asset/markdown.js +8 -0
  75. package/dist/core/config/config-schema.js +255 -2
  76. package/dist/core/config/config.js +2 -2
  77. package/dist/core/logs-db.js +305 -0
  78. package/dist/core/paths.js +3 -0
  79. package/dist/core/state-db.js +706 -42
  80. package/dist/indexer/db/db.js +364 -38
  81. package/dist/indexer/db/graph-db.js +129 -86
  82. package/dist/indexer/ensure-index.js +152 -17
  83. package/dist/indexer/graph/graph-boost.js +51 -41
  84. package/dist/indexer/graph/graph-extraction.js +203 -3
  85. package/dist/indexer/index-writer-lock.js +99 -0
  86. package/dist/indexer/indexer.js +114 -111
  87. package/dist/indexer/passes/memory-inference.js +71 -25
  88. package/dist/indexer/passes/staleness-detect.js +2 -5
  89. package/dist/indexer/search/db-search.js +15 -4
  90. package/dist/indexer/search/ranking-contributors.js +22 -0
  91. package/dist/indexer/search/ranking.js +4 -0
  92. package/dist/indexer/walk/matchers.js +9 -0
  93. package/dist/integrations/agent/prompts.js +1 -0
  94. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  95. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  96. package/dist/integrations/session-logs/index.js +16 -0
  97. package/dist/llm/client.js +38 -4
  98. package/dist/llm/embedder.js +27 -3
  99. package/dist/llm/embedders/local.js +66 -2
  100. package/dist/llm/graph-extract.js +2 -1
  101. package/dist/llm/memory-infer.js +4 -8
  102. package/dist/llm/metadata-enhance.js +9 -1
  103. package/dist/llm/usage-persist.js +77 -0
  104. package/dist/llm/usage-telemetry.js +103 -0
  105. package/dist/output/context.js +3 -2
  106. package/dist/output/html-render.js +73 -0
  107. package/dist/output/renderers.js +73 -1
  108. package/dist/output/shapes/curate.js +14 -2
  109. package/dist/output/shapes/helpers.js +17 -1
  110. package/dist/output/text/helpers.js +78 -1
  111. package/dist/runtime.js +25 -1
  112. package/dist/scripts/migrate-storage.js +1262 -591
  113. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +485 -270
  114. package/dist/sources/providers/tar-utils.js +16 -8
  115. package/dist/storage/sqlite-pragmas.js +146 -0
  116. package/dist/tasks/runner.js +99 -16
  117. package/dist/workflows/db.js +5 -2
  118. package/dist/workflows/validate-summary.js +2 -7
  119. package/docs/data-and-telemetry.md +1 -0
  120. package/package.json +9 -6
@@ -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, withImmediateTransaction, } 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";
@@ -66,6 +74,8 @@ export const PROPOSAL_SOURCES = [
66
74
  "consolidate",
67
75
  "extract",
68
76
  "improve",
77
+ "recombine",
78
+ "procedural",
69
79
  // Semi-automated / tool-driven.
70
80
  "feedback",
71
81
  // Human-initiated / CLI-driven.
@@ -83,6 +93,8 @@ export const AUTOMATED_PROPOSAL_SOURCES = [
83
93
  "consolidate",
84
94
  "extract",
85
95
  "improve",
96
+ "recombine",
97
+ "procedural",
86
98
  "schema-repair",
87
99
  ];
88
100
  /**
@@ -131,21 +143,7 @@ function cooldownMsForSource(source) {
131
143
  function contentHash(content) {
132
144
  return createHash("sha256").update(content, "utf8").digest("hex");
133
145
  }
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
- }
146
+ // ── Store access ─────────────────────────────────────────────────────────────
149
147
  function nowIso(ctx) {
150
148
  const fn = ctx?.now ?? Date.now;
151
149
  return new Date(fn()).toISOString();
@@ -154,35 +152,124 @@ function newId(ctx) {
154
152
  const fn = ctx?.randomUUID ?? randomUUID;
155
153
  return fn();
156
154
  }
157
- // ── Read / write primitives ─────────────────────────────────────────────────
158
- function readProposalFile(filePath) {
159
- let raw;
155
+ /**
156
+ * Open the state database (honouring the `ctx.dbPath` test seam), run the
157
+ * legacy filesystem import for `stashDir` if it has not happened yet, hand the
158
+ * connection to `fn`, and close it in a `finally`. Every public function in
159
+ * this module funnels its store access through here so the legacy import is
160
+ * guaranteed to have run before any read or write.
161
+ */
162
+ function withProposalsDb(stashDir, ctx, fn) {
163
+ const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
160
164
  try {
161
- raw = fs.readFileSync(filePath, "utf8");
165
+ importLegacyProposalFiles(db, stashDir);
166
+ return fn(db);
162
167
  }
163
- catch (err) {
164
- throw new NotFoundError(`Proposal not found at ${filePath}.`, "FILE_NOT_FOUND", `The proposal file is missing or unreadable: ${err.message}`);
168
+ finally {
169
+ db.close();
170
+ }
171
+ }
172
+ // ── Legacy filesystem import (#578) ─────────────────────────────────────────
173
+ /** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
174
+ function legacyProposalsRoot(stashDir, archive) {
175
+ const root = path.join(stashDir, ".akm", "proposals");
176
+ return archive ? path.join(root, "archive") : root;
177
+ }
178
+ /**
179
+ * One-shot import of legacy `proposal.json` files into the `proposals` table.
180
+ *
181
+ * Idempotent at two levels: the `proposal_fs_imports` ledger skips the
182
+ * directory walk after the first successful import, and INSERT OR IGNORE
183
+ * (keyed on the proposal UUID) protects against duplicates even if the walk
184
+ * re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
185
+ * `akm proposal revert` keeps working for proposals accepted before 0.9.0.
186
+ *
187
+ * The legacy files are never modified or deleted — after import they are
188
+ * inert artifacts the operator can remove at leisure.
189
+ */
190
+ function importLegacyProposalFiles(db, stashDir) {
191
+ if (hasImportedFsProposals(db, stashDir))
192
+ return;
193
+ const liveRoot = legacyProposalsRoot(stashDir, false);
194
+ if (!fs.existsSync(liveRoot))
195
+ return;
196
+ let imported = 0;
197
+ for (const archive of [false, true]) {
198
+ const root = legacyProposalsRoot(stashDir, archive);
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(root, { withFileTypes: true });
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ for (const entry of entries) {
207
+ if (!entry.isDirectory() || entry.name === "archive")
208
+ continue;
209
+ const proposalDir = path.join(root, entry.name);
210
+ const proposal = readLegacyProposalFile(proposalDir);
211
+ if (!proposal)
212
+ continue;
213
+ if (insertProposalIfAbsent(db, proposal, stashDir))
214
+ imported += 1;
215
+ }
165
216
  }
217
+ recordFsProposalsImport(db, stashDir, imported);
218
+ if (imported > 0) {
219
+ warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
220
+ }
221
+ }
222
+ /**
223
+ * Parse one legacy proposal directory into a {@link Proposal}, inlining the
224
+ * backup file (when present) as `backupContent`. Returns undefined — with a
225
+ * warning — when the `proposal.json` is missing, unreadable, or malformed, so
226
+ * a single corrupt legacy entry never blocks the import of the rest.
227
+ */
228
+ function readLegacyProposalFile(proposalDir) {
229
+ const filePath = path.join(proposalDir, "proposal.json");
166
230
  let parsed;
167
231
  try {
168
- parsed = JSON.parse(raw);
232
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
169
233
  }
170
234
  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>/.");
235
+ warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
236
+ return undefined;
172
237
  }
173
- if (typeof parsed !== "object" || parsed === null) {
174
- throw new UsageError(`Proposal file at ${filePath} is not a JSON object.`, "INVALID_JSON_ARGUMENT");
238
+ if (typeof parsed !== "object" ||
239
+ parsed === null ||
240
+ typeof parsed.id !== "string" ||
241
+ typeof parsed.ref !== "string") {
242
+ warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
243
+ return undefined;
175
244
  }
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");
245
+ const { backup, ...rest } = parsed;
246
+ let backupContent;
247
+ if (typeof backup === "string" && backup.length > 0) {
248
+ try {
249
+ backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
250
+ }
251
+ catch {
252
+ // Backup file lost — import the proposal anyway; revert for it will
253
+ // surface "no backup available", same as a new-asset proposal.
254
+ }
255
+ }
256
+ return {
257
+ ...rest,
258
+ payload: {
259
+ content: rest.payload?.content ?? "",
260
+ ...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
261
+ },
262
+ createdAt: rest.createdAt ?? "",
263
+ updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
264
+ status: rest.status ?? "pending",
265
+ source: rest.source ?? "import",
266
+ ...(backupContent !== undefined ? { backupContent } : {}),
267
+ };
181
268
  }
182
269
  // ── Public API ──────────────────────────────────────────────────────────────
183
270
  /**
184
271
  * Create a new pending proposal. The id is a stable random UUID, so two
185
- * proposals with the same `ref` never collide on disk.
272
+ * proposals with the same `ref` never collide.
186
273
  *
187
274
  * **Dedup / cooldown guard** (F-2 / #363):
188
275
  *
@@ -196,7 +283,7 @@ function writeProposalFile(filePath, proposal) {
196
283
  * others: 7 d). Bypass with `force: true`.
197
284
  *
198
285
  * When a guard fires the function returns a `CreateProposalSkipped` record
199
- * instead of writing to disk. Use {@link isProposalSkipped} to detect it.
286
+ * instead of writing. Use {@link isProposalSkipped} to detect it.
200
287
  */
201
288
  export function createProposal(stashDir, input, ctx) {
202
289
  // F-4 / #385: Validate source against the allow-list. Unknown values are
@@ -250,173 +337,149 @@ export function createProposal(stashDir, input, ctx) {
250
337
  }
251
338
  }
252
339
  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
- };
340
+ return withProposalsDb(stashDir, ctx, (db) => {
341
+ return withImmediateTransaction(db, () => {
342
+ if (!input.force) {
343
+ const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
344
+ if (skip)
345
+ return skip;
269
346
  }
270
- // Duplicate pending for same ref+source (different content).
271
- const firstPending = pending[0];
347
+ const created = nowIso(ctx);
348
+ // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
349
+ // is dropped silently — we never store NaN, Infinity, or out-of-range values.
350
+ // Callers that mis-report confidence should not poison the auto-accept gate.
351
+ const sanitizedConfidence = typeof input.confidence === "number" &&
352
+ Number.isFinite(input.confidence) &&
353
+ input.confidence >= 0 &&
354
+ input.confidence <= 1
355
+ ? input.confidence
356
+ : undefined;
357
+ const proposal = {
358
+ id: newId(ctx),
359
+ ref: normalizedRef,
360
+ status: "pending",
361
+ source: input.source,
362
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
363
+ createdAt: created,
364
+ updatedAt: created,
365
+ payload: {
366
+ content: input.payload.content,
367
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
368
+ },
369
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
370
+ // Attribution tagging: persist the eligibility lane so it survives to
371
+ // accept/reject/revert time. See EligibilitySource.
372
+ ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
373
+ };
374
+ upsertProposal(db, proposal, stashDir);
375
+ return proposal;
376
+ });
377
+ });
378
+ }
379
+ /**
380
+ * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
381
+ * skip record when a guard fires, or undefined when the create may proceed.
382
+ */
383
+ function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
384
+ const newHash = contentHash(input.payload.content);
385
+ const nowMs = (ctx?.now ?? Date.now)();
386
+ const cooldownMs = cooldownMsForSource(input.source);
387
+ // Scan pending proposals for ref+source matches.
388
+ const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
389
+ if (pending.length > 0) {
390
+ // Check for identical content hash first (silent skip).
391
+ const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
392
+ if (hashMatch) {
272
393
  return {
273
394
  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,
395
+ reason: "content_hash_match",
396
+ message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
397
+ existingProposalId: hashMatch.id,
277
398
  };
278
399
  }
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
- }
400
+ // Duplicate pending for same ref+source (different content).
401
+ const firstPending = pending[0];
402
+ return {
403
+ skipped: true,
404
+ reason: "duplicate_pending",
405
+ message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
406
+ existingProposalId: firstPending?.id,
407
+ };
408
+ }
409
+ // Check cooldown against recently rejected proposals.
410
+ const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
411
+ .filter((p) => p.source === input.source)
412
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
413
+ const mostRecent = rejected[0];
414
+ if (mostRecent !== undefined) {
415
+ // Check content hash against recently rejected.
416
+ if (contentHash(mostRecent.payload.content) === newHash) {
417
+ return {
418
+ skipped: true,
419
+ reason: "content_hash_match",
420
+ message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
421
+ existingProposalId: mostRecent.id,
422
+ };
423
+ }
424
+ // Check cooldown window.
425
+ const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
426
+ if (nowMs - rejectedAt < cooldownMs) {
427
+ const cooldownDays = cooldownMs / MS_PER_DAY;
428
+ const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
429
+ return {
430
+ skipped: true,
431
+ reason: "cooldown",
432
+ message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
433
+ `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
434
+ existingProposalId: mostRecent.id,
435
+ };
307
436
  }
308
437
  }
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;
438
+ return undefined;
336
439
  }
337
440
  /**
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.
441
+ * List proposals for one stash. By default returns only the live (pending)
442
+ * queue; pass `{ includeArchive: true }` to include accepted / rejected /
443
+ * reverted entries as well.
341
444
  */
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 });
445
+ export function listProposals(stashDir, options = {}, ctx) {
446
+ return withProposalsDb(stashDir, ctx, (db) => {
447
+ // Without includeArchive, only the live queue is visible — an explicit
448
+ // non-pending status filter therefore matches nothing (mirrors the
449
+ // historical live-directory scan).
450
+ if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
451
+ return [];
354
452
  }
355
- catch {
356
- continue;
357
- }
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;
453
+ const status = options.includeArchive ? options.status : "pending";
454
+ return listStateProposals(db, {
455
+ stashDir,
456
+ ...(status !== undefined ? { status } : {}),
457
+ ...(options.ref !== undefined ? { ref: options.ref } : {}),
458
+ }).filter((p) => {
459
+ if (!options.type)
460
+ return true;
367
461
  try {
368
- out.push(readProposalFile(filePath));
462
+ return parseAssetRef(p.ref).type === options.type;
369
463
  }
370
464
  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
- });
465
+ return false;
388
466
  }
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));
467
+ });
468
+ });
407
469
  }
408
470
  /**
409
- * Look up a proposal by id. Searches the live queue first, then the archive.
410
- * Throws `NotFoundError` when no match exists.
471
+ * Look up a proposal by id (live or archived).
472
+ * Throws `NotFoundError` when no match exists in this stash.
411
473
  */
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");
474
+ export function getProposal(stashDir, id, ctx) {
475
+ return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
476
+ }
477
+ function requireProposal(db, stashDir, id) {
478
+ const proposal = getStateProposal(db, id, stashDir);
479
+ if (!proposal) {
480
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
481
+ }
482
+ return proposal;
420
483
  }
421
484
  /**
422
485
  * Resolve a proposal by full UUID, UUID prefix, or asset ref.
@@ -425,95 +488,93 @@ export function getProposal(stashDir, id) {
425
488
  * 1. Exact UUID match (existing behaviour).
426
489
  * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
427
490
  * 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.
491
+ * 3. UUID prefix — matches any PENDING proposal whose id starts with the
492
+ * given string; throws if ambiguous.
430
493
  */
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];
494
+ export function resolveProposalId(stashDir, idOrRef, ctx) {
495
+ return withProposalsDb(stashDir, ctx, (db) => {
496
+ // 1. Exact UUID.
497
+ const exact = getStateProposal(db, idOrRef, stashDir);
498
+ if (exact)
499
+ return exact;
500
+ // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
501
+ // recent archived.
502
+ if (idOrRef.includes(":")) {
503
+ const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
504
+ const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
505
+ if (pending)
506
+ return pending;
507
+ const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
508
+ if (archived)
509
+ return archived;
510
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
445
511
  }
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];
512
+ // 3. UUID prefix (pending queue only).
513
+ const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
514
+ if (prefixMatches.length === 1)
515
+ return requireProposal(db, stashDir, prefixMatches[0]);
516
+ if (prefixMatches.length > 1) {
517
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
449
518
  }
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");
519
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
520
+ });
467
521
  }
468
522
  /**
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).
471
- */
472
- export function isProposalArchived(stashDir, id) {
473
- return !fs.existsSync(proposalFile(stashDir, id, false)) && fs.existsSync(proposalFile(stashDir, id, true));
474
- }
475
- /**
476
- * Move a proposal directory into the archive subtree and update its status.
477
- * Used by both accept (status `accepted`) and reject (status `rejected`)
523
+ * Archive a proposal: flip its status to `accepted` / `rejected`, bump
524
+ * `updatedAt`, and record the review block. Used by both accept and reject
478
525
  * paths so the live queue only contains pending entries.
479
526
  */
480
527
  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);
528
+ return withProposalsDb(stashDir, ctx, (db) => {
529
+ return withImmediateTransaction(db, () => {
530
+ const existing = requireProposal(db, stashDir, id);
531
+ if (existing.status !== "pending") {
532
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
533
+ }
534
+ const decidedAt = nowIso(ctx);
487
535
  const updated = {
488
536
  ...existing,
489
537
  status,
490
- updatedAt: nowIso(ctx),
538
+ updatedAt: decidedAt,
491
539
  review: {
492
540
  outcome: status,
493
541
  ...(reason !== undefined ? { reason } : {}),
494
- decidedAt: nowIso(ctx),
542
+ decidedAt,
495
543
  },
496
544
  };
497
- writeProposalFile(archived, updated);
545
+ upsertProposal(db, updated, stashDir);
498
546
  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;
547
+ });
548
+ });
549
+ }
550
+ /**
551
+ * Record an automated gate's decision onto a proposal (#577).
552
+ *
553
+ * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
554
+ * row so `akm proposal show` and `list` can explain why a proposal landed where
555
+ * it did. The decision is metadata about the adjudication, so this does NOT
556
+ * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
557
+ * and the accept / reject status flips are owned by {@link promoteProposal} /
558
+ * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
559
+ *
560
+ * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
561
+ * skipped silently rather than throwing, so a gate run never aborts mid-batch.
562
+ * Returns the updated proposal, or undefined when no matching row exists.
563
+ */
564
+ export function recordGateDecision(stashDir, id, decision, ctx) {
565
+ return withProposalsDb(stashDir, ctx, (db) => {
566
+ return withImmediateTransaction(db, () => {
567
+ const existing = getStateProposal(db, id, stashDir);
568
+ if (!existing || existing.status !== "pending")
569
+ return undefined;
570
+ const updated = {
571
+ ...existing,
572
+ gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
573
+ };
574
+ upsertProposal(db, updated, stashDir);
575
+ return updated;
576
+ });
577
+ });
517
578
  }
518
579
  /**
519
580
  * Scan all pending proposals and reject those whose target asset no longer
@@ -529,7 +590,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
529
590
  const t0 = Date.now();
530
591
  const orphans = [];
531
592
  const byType = {};
532
- const pending = listProposals(stashDir, { status: "pending" });
593
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
533
594
  const reflectPending = pending.filter((p) => p.source === "reflect");
534
595
  for (const p of reflectPending) {
535
596
  let parsed;
@@ -608,7 +669,7 @@ export function expireStaleProposals(stashDir, config, ctx) {
608
669
  }
609
670
  const retentionMs = retentionDays * MS_PER_DAY;
610
671
  const nowMs = (ctx?.now ?? Date.now)();
611
- const pending = listProposals(stashDir, { status: "pending" });
672
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
612
673
  for (const p of pending) {
613
674
  const createdMs = new Date(p.createdAt).getTime();
614
675
  if (!Number.isFinite(createdMs))
@@ -658,18 +719,17 @@ export function validateProposal(proposal) {
658
719
  /**
659
720
  * Validate a proposal, then promote it through the canonical
660
721
  * {@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.
722
+ * `source.kind`). On success the proposal is archived with status `accepted`.
723
+ * Validation failures throw a `UsageError` carrying every finding so the CLI
724
+ * can render a single clear error envelope.
664
725
  *
665
726
  * 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.
727
+ * its prior content is captured BEFORE the write and stored on the archived
728
+ * proposal record (`backupContent`) so `akm proposal revert` can restore it.
729
+ * Genuinely-new assets carry no backup.
670
730
  */
671
731
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
672
- const proposal = getProposal(stashDir, id);
732
+ const proposal = getProposal(stashDir, id, ctx);
673
733
  if (proposal.status !== "pending") {
674
734
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
675
735
  }
@@ -683,25 +743,15 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
683
743
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
684
744
  }
685
745
  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
746
+ // Phase 6C: capture the prior content (if any) BEFORE writing the new
747
+ // asset. We use the resolved write target to compute the exact path the
688
748
  // asset would land at — same resolver `writeAssetToSource` uses — so the
689
749
  // backup always mirrors what would be overwritten.
690
- let backupRelPath;
750
+ let backupContent;
691
751
  try {
692
752
  const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
693
753
  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;
754
+ backupContent = fs.readFileSync(targetFilePath, "utf8");
705
755
  }
706
756
  }
707
757
  catch (err) {
@@ -715,64 +765,54 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
715
765
  // targets. No-op for filesystem/primary-stash targets.
716
766
  commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
717
767
  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);
768
+ // Persist the backup content on the archived proposal record so the revert
769
+ // flow can restore the prior asset state.
770
+ if (backupContent !== undefined) {
771
+ const withBackup = { ...archived, backupContent };
772
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
725
773
  return { proposal: withBackup, assetPath: written.path, ref: written.ref };
726
774
  }
727
775
  return { proposal: archived, assetPath: written.path, ref: written.ref };
728
776
  }
729
777
  /**
730
- * Restore the prior content of an accepted proposal from its captured backup
731
- * (Advantage D6c / Phase 6C).
778
+ * Restore the prior content of an accepted proposal from the backup captured
779
+ * at promotion time (Advantage D6c / Phase 6C).
732
780
  *
733
781
  * Pre-conditions:
734
782
  * - `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.
783
+ * - The proposal carries `backupContent` (captured by promoteProposal when
784
+ * the target asset existed before the write).
737
785
  *
738
786
  * On success:
739
787
  * - The backup content is written back through {@link writeAssetToSource},
740
788
  * so the canonical write-dispatch invariant is preserved.
741
- * - The archived proposal record is updated to `status: "reverted"`.
789
+ * - The proposal record is updated to `status: "reverted"`.
742
790
  * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
743
791
  * `promoted` / `rejected` are emitted by the CLI command, not the core).
744
792
  *
745
793
  * 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.
794
+ * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
795
+ * wrapper.
747
796
  */
748
797
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
749
- const proposal = getProposal(stashDir, id);
798
+ const proposal = getProposal(stashDir, id, ctx);
750
799
  if (proposal.status !== "accepted") {
751
800
  throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
752
801
  }
753
- if (!proposal.backup) {
802
+ if (proposal.backupContent === undefined) {
754
803
  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
804
  }
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
805
  const ref = parseAssetRef(proposal.ref);
765
806
  if (!TYPE_DIRS[ref.type]) {
766
807
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
767
808
  }
768
809
  const target = resolveWriteTarget(config, options.target);
769
- const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
810
+ const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
770
811
  // 0.9.0 (issue #507): single batch commit at the write boundary for git
771
812
  // targets. No-op for filesystem/primary-stash targets.
772
813
  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);
814
+ // Update the proposal record to status: "reverted" and bump updatedAt +
815
+ // review so the audit trail reflects the second decision.
776
816
  const now = nowIso(ctx);
777
817
  const reverted = {
778
818
  ...proposal,
@@ -784,7 +824,7 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
784
824
  decidedAt: now,
785
825
  },
786
826
  };
787
- writeProposalFile(archivedFile, reverted);
827
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
788
828
  return { proposal: reverted, assetPath: written.path, ref: written.ref };
789
829
  }
790
830
  /**
@@ -793,8 +833,8 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
793
833
  * diff matches exactly what `accept` will write. Falls back to "new asset"
794
834
  * when no asset is currently materialised at the target ref.
795
835
  */
796
- export function diffProposal(stashDir, config, id, options = {}) {
797
- const proposal = getProposal(stashDir, id);
836
+ export function diffProposal(stashDir, config, id, options = {}, ctx) {
837
+ const proposal = getProposal(stashDir, id, ctx);
798
838
  const ref = parseAssetRef(proposal.ref);
799
839
  let targetPath;
800
840
  let existing = null;