akm-cli 0.9.0-beta.54 → 0.9.0-beta.55

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 (101) hide show
  1. package/dist/cli.js +5 -3
  2. package/dist/commands/agent/contribute-cli.js +2 -3
  3. package/dist/commands/env/env-cli.js +187 -202
  4. package/dist/commands/env/secret-cli.js +109 -121
  5. package/dist/commands/feedback-cli.js +152 -155
  6. package/dist/commands/health/advisories.js +151 -0
  7. package/dist/commands/health/improve-metrics.js +754 -0
  8. package/dist/commands/health/llm-usage.js +65 -0
  9. package/dist/commands/health/md-report.js +103 -0
  10. package/dist/commands/health/metrics.js +278 -0
  11. package/dist/commands/health/task-runs.js +135 -0
  12. package/dist/commands/health/types.js +18 -0
  13. package/dist/commands/health/windows.js +196 -0
  14. package/dist/commands/health.js +14 -1624
  15. package/dist/commands/improve/anti-collapse.js +170 -0
  16. package/dist/commands/improve/collapse-detector.js +3 -2
  17. package/dist/commands/improve/consolidate.js +636 -633
  18. package/dist/commands/improve/dedup.js +1 -1
  19. package/dist/commands/improve/distill/content-repair.js +202 -0
  20. package/dist/commands/improve/distill/promote-memory.js +228 -0
  21. package/dist/commands/improve/distill/quality-gate.js +233 -0
  22. package/dist/commands/improve/distill-guards.js +127 -0
  23. package/dist/commands/improve/distill.js +49 -575
  24. package/dist/commands/improve/extract-cli.js +74 -76
  25. package/dist/commands/improve/extract.js +6 -4
  26. package/dist/commands/improve/hot-probation.js +45 -0
  27. package/dist/commands/improve/improve-auto-accept.js +3 -2
  28. package/dist/commands/improve/improve-cli.js +14 -13
  29. package/dist/commands/improve/improve-result-file.js +2 -1
  30. package/dist/commands/improve/improve.js +6 -5
  31. package/dist/commands/improve/loop-stages.js +19 -21
  32. package/dist/commands/improve/preparation.js +4 -2
  33. package/dist/commands/improve/procedural.js +10 -31
  34. package/dist/commands/improve/recombine.js +19 -43
  35. package/dist/commands/improve/reflect.js +1 -1
  36. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  37. package/dist/commands/improve/shared.js +48 -0
  38. package/dist/commands/observability-cli.js +4 -4
  39. package/dist/commands/proposal/drain-policies.js +2 -2
  40. package/dist/commands/proposal/drain.js +1 -1
  41. package/dist/commands/proposal/legacy-import.js +115 -0
  42. package/dist/commands/proposal/proposal-cli.js +3 -3
  43. package/dist/commands/proposal/proposal.js +2 -1
  44. package/dist/commands/proposal/propose.js +1 -1
  45. package/dist/commands/proposal/repository.js +829 -0
  46. package/dist/commands/proposal/validators/proposals.js +5 -920
  47. package/dist/commands/read/remember-cli.js +132 -137
  48. package/dist/commands/read/search-cli.js +1 -1
  49. package/dist/commands/registry-cli.js +76 -87
  50. package/dist/commands/sources/add-cli.js +90 -94
  51. package/dist/commands/sources/history.js +1 -1
  52. package/dist/commands/sources/schema-repair.js +1 -1
  53. package/dist/commands/sources/sources-cli.js +3 -3
  54. package/dist/commands/sources/stash-cli.js +1 -1
  55. package/dist/commands/tasks/tasks-cli.js +1 -2
  56. package/dist/commands/wiki-cli.js +2 -3
  57. package/dist/core/common.js +3 -3
  58. package/dist/core/config/config-schema.js +6 -0
  59. package/dist/core/deep-merge.js +38 -0
  60. package/dist/core/events.js +2 -1
  61. package/dist/core/logs-db.js +8 -13
  62. package/dist/core/paths.js +14 -14
  63. package/dist/core/state-db.js +13 -1140
  64. package/dist/indexer/db/db.js +66 -709
  65. package/dist/indexer/db/entry-mapper.js +41 -0
  66. package/dist/indexer/db/schema.js +516 -0
  67. package/dist/indexer/feedback/utility-policy.js +85 -0
  68. package/dist/indexer/graph/graph-extraction.js +2 -1
  69. package/dist/indexer/index-writer-lock.js +9 -0
  70. package/dist/indexer/indexer.js +78 -23
  71. package/dist/indexer/search/fts-query.js +51 -0
  72. package/dist/integrations/agent/spawn.js +15 -66
  73. package/dist/output/text/helpers.js +13 -0
  74. package/dist/scripts/migrate-storage.js +6891 -7436
  75. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +44 -43
  76. package/dist/setup/legacy-config.js +106 -0
  77. package/dist/setup/prompt.js +57 -0
  78. package/dist/setup/providers.js +14 -0
  79. package/dist/setup/semantic-assets.js +124 -0
  80. package/dist/setup/setup.js +24 -1607
  81. package/dist/setup/steps/connection.js +734 -0
  82. package/dist/setup/steps/output.js +31 -0
  83. package/dist/setup/steps/platforms.js +124 -0
  84. package/dist/setup/steps/semantic.js +27 -0
  85. package/dist/setup/steps/sources.js +222 -0
  86. package/dist/setup/steps/stashdir.js +42 -0
  87. package/dist/setup/steps/tasks.js +152 -0
  88. package/dist/storage/repositories/canaries-repository.js +107 -0
  89. package/dist/storage/repositories/consolidation-repository.js +38 -0
  90. package/dist/storage/repositories/embeddings-repository.js +72 -0
  91. package/dist/storage/repositories/events-repository.js +187 -0
  92. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  93. package/dist/storage/repositories/improve-runs-repository.js +130 -0
  94. package/dist/storage/repositories/index-db.js +4 -7
  95. package/dist/storage/repositories/proposals-repository.js +220 -0
  96. package/dist/storage/repositories/recombine-repository.js +213 -0
  97. package/dist/storage/repositories/task-history-repository.js +93 -0
  98. package/dist/storage/sqlite-pragmas.js +3 -3
  99. package/dist/tasks/runner.js +2 -1
  100. package/package.json +1 -1
  101. package/dist/commands/improve/homeostatic.js +0 -497
@@ -0,0 +1,829 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Proposal substrate (#225, storage consolidated in #578).
6
+ *
7
+ * One durable proposal store for every future reflection / generation flow
8
+ * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
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
+ * `akm proposal accept` validates and promotes them via
12
+ * {@link writeAssetToSource}.
13
+ *
14
+ * # Storage
15
+ *
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.
23
+ *
24
+ * ## Legacy filesystem import
25
+ *
26
+ * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
+ * `<stashDir>/.akm/proposals/`. The first proposal operation against a stash
28
+ * imports any legacy `proposal.json` files into the table — see
29
+ * `./legacy-import.ts` (`importLegacyProposalFiles`), funnelled through
30
+ * {@link withProposalsDb}.
31
+ *
32
+ * # Why the queue bypasses `writeAssetToSource`
33
+ *
34
+ * The architectural rule "all writes go through `writeAssetToSource`" applies
35
+ * to *assets*. Proposals are **not** assets — they live outside the asset
36
+ * tree (in state.db, parallel to how events do). Routing them through
37
+ * `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
38
+ * them to git, and would leak unaccepted drafts through the normal indexer.
39
+ * The {@link promoteProposal} step is the bridge: it routes the accepted
40
+ * payload through `writeAssetToSource` so the actual asset write still
41
+ * funnels through the single dispatch point in `src/core/write-source.ts`.
42
+ */
43
+ import { createHash, randomUUID } from "node:crypto";
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+ import { makeAssetRef, parseAssetRef } from "../../core/asset/asset-ref.js";
47
+ import { resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
48
+ import { NotFoundError, UsageError } from "../../core/errors.js";
49
+ import { appendEvent } from "../../core/events.js";
50
+ import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
51
+ import { warn } from "../../core/warn.js";
52
+ import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
53
+ import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
54
+ import { importLegacyProposalFiles } from "./legacy-import.js";
55
+ import { repairProposalContent, validateProposal } from "./validators/proposals.js";
56
+ // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
57
+ /**
58
+ * Curated allow-list of valid `source` values for proposals (F-4 / #385).
59
+ *
60
+ * Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
61
+ * sources for meaningful aggregation. Accept-rate-per-source is the core
62
+ * self-measurement metric for recursive self-improvement: if reflect proposals
63
+ * are accepted at 20% and distill proposals at 60%, that guides resource
64
+ * allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
65
+ *
66
+ * Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
67
+ * `sourceRun` field for full PROV-DM traceability.
68
+ */
69
+ export const PROPOSAL_SOURCES = [
70
+ // Automated sources — require sourceRun for traceability.
71
+ "reflect",
72
+ "distill",
73
+ "consolidate",
74
+ "extract",
75
+ "improve",
76
+ "recombine",
77
+ "procedural",
78
+ // Semi-automated / tool-driven.
79
+ "feedback",
80
+ // Human-initiated / CLI-driven.
81
+ "propose",
82
+ "remember",
83
+ "import",
84
+ // Internal / system.
85
+ "distill_quality_rejected",
86
+ "schema-repair",
87
+ ];
88
+ /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
89
+ export const AUTOMATED_PROPOSAL_SOURCES = [
90
+ "reflect",
91
+ "distill",
92
+ "consolidate",
93
+ "extract",
94
+ "improve",
95
+ "recombine",
96
+ "procedural",
97
+ "schema-repair",
98
+ ];
99
+ /**
100
+ * Check whether a string is a valid {@link ProposalSource}.
101
+ * Unknown source values are accepted with a runtime warning rather than a hard
102
+ * error, to allow extensions without breaking existing callers.
103
+ */
104
+ export function isValidProposalSource(source) {
105
+ return PROPOSAL_SOURCES.includes(source);
106
+ }
107
+ /**
108
+ * Check whether a source value is an automated source requiring `sourceRun`.
109
+ */
110
+ export function isAutomatedProposalSource(source) {
111
+ return AUTOMATED_PROPOSAL_SOURCES.includes(source);
112
+ }
113
+ /** Type guard: true when createProposal returned a skipped record. */
114
+ export function isProposalSkipped(result) {
115
+ return result.skipped === true;
116
+ }
117
+ // ── Dedup / cooldown constants ───────────────────────────────────────────────
118
+ const MS_PER_DAY = 86_400_000;
119
+ /**
120
+ * Post-rejection cooldown windows by source. After a proposal is rejected,
121
+ * `createProposal` silently skips new proposals for the same `ref+source`
122
+ * until the window expires (unless `force: true` is passed).
123
+ *
124
+ * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
125
+ * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
126
+ * prevent nightly improve runs from re-flooding the queue with near-identical
127
+ * proposals the reviewer just declined.
128
+ *
129
+ * - reflect: 14 days (agent-based; slower feedback loops)
130
+ * - distill: 30 days (LLM-based; even more prone to regeneration loops)
131
+ * - default: 7 days (conservative fallback for other sources)
132
+ */
133
+ const COOLDOWN_MS = {
134
+ reflect: 14 * MS_PER_DAY,
135
+ distill: 30 * MS_PER_DAY,
136
+ };
137
+ const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
138
+ function cooldownMsForSource(source) {
139
+ return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
140
+ }
141
+ /** Compute a stable SHA-256 hex digest of a proposal's content string. */
142
+ function contentHash(content) {
143
+ return createHash("sha256").update(content, "utf8").digest("hex");
144
+ }
145
+ // ── Store access ─────────────────────────────────────────────────────────────
146
+ function nowIso(ctx) {
147
+ const fn = ctx?.now ?? Date.now;
148
+ return new Date(fn()).toISOString();
149
+ }
150
+ function newId(ctx) {
151
+ const fn = ctx?.randomUUID ?? randomUUID;
152
+ return fn();
153
+ }
154
+ /**
155
+ * Open the state database (honouring the `ctx.dbPath` test seam), run the
156
+ * legacy filesystem import for `stashDir` if it has not happened yet, hand the
157
+ * connection to `fn`, and close it in a `finally`. Every public function in
158
+ * this module funnels its store access through here so the legacy import is
159
+ * guaranteed to have run before any read or write.
160
+ */
161
+ function withProposalsDb(stashDir, ctx, fn) {
162
+ return withStateDb((db) => {
163
+ importLegacyProposalFiles(db, stashDir);
164
+ return fn(db);
165
+ }, { path: ctx?.dbPath });
166
+ }
167
+ // ── Public API ──────────────────────────────────────────────────────────────
168
+ /**
169
+ * Create a new pending proposal. The id is a stable random UUID, so two
170
+ * proposals with the same `ref` never collide.
171
+ *
172
+ * **Dedup / cooldown guard** (F-2 / #363):
173
+ *
174
+ * Before writing, this function checks:
175
+ * 1. `duplicate_pending` — a pending proposal already exists for the same
176
+ * `ref+source`. Pass `input.force = true` to bypass.
177
+ * 2. `content_hash_match` — an identical content hash is already pending or
178
+ * was recently rejected for this `ref+source`. Bypass with `force: true`.
179
+ * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
180
+ * source-specific cooldown window (reflect: 14 d, distill: 30 d,
181
+ * others: 7 d). Bypass with `force: true`.
182
+ *
183
+ * When a guard fires the function returns a `CreateProposalSkipped` record
184
+ * instead of writing. Use {@link isProposalSkipped} to detect it.
185
+ */
186
+ export function createProposal(stashDir, input, ctx) {
187
+ // F-4 / #385: Validate source against the allow-list. Unknown values are
188
+ // warned (not rejected) for backward compatibility — extension callers
189
+ // that pass custom source strings must not break.
190
+ if (!isValidProposalSource(input.source)) {
191
+ warn(`[proposal] Unknown source "${input.source}". ` +
192
+ `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
193
+ "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
194
+ }
195
+ else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
196
+ // Advisory warning: automated sources should include sourceRun for PROV-DM
197
+ // traceability. This is not a hard error to avoid breaking existing callers.
198
+ warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
199
+ "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
200
+ }
201
+ // Deterministic input validation. Reject obviously-invalid proposals at
202
+ // the source rather than letting them enter the queue and waste reviewer
203
+ // time. Each rejection emits `proposal_creation_rejected` with a typed
204
+ // reason so we can see *which* check is firing in the event stream.
205
+ const rejectProposal = (reason, message) => {
206
+ appendEvent({
207
+ eventType: "proposal_creation_rejected",
208
+ ref: input.ref,
209
+ metadata: { source: input.source, reason },
210
+ });
211
+ throw new UsageError(message, "INVALID_PROPOSAL");
212
+ };
213
+ let parsedRef;
214
+ try {
215
+ parsedRef = parseAssetRef(input.ref);
216
+ }
217
+ catch (err) {
218
+ return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
219
+ }
220
+ if (!TYPE_DIRS[parsedRef.type]) {
221
+ return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
222
+ }
223
+ if (!input.payload.content.trim()) {
224
+ return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
225
+ }
226
+ // Description check is only enforced for `consolidate` source — that's the
227
+ // automated pipeline that historically produced proposals with missing or
228
+ // malformed frontmatter, polluting the queue with hundreds of unusable
229
+ // entries. Reflect / distill / propose proposals have varied legitimate
230
+ // shapes and should not be rejected here for missing description.
231
+ if (input.source === "consolidate") {
232
+ const desc = input.payload.frontmatter?.description;
233
+ if (typeof desc !== "string" || desc.trim() === "") {
234
+ return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
235
+ }
236
+ }
237
+ const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
238
+ return withProposalsDb(stashDir, ctx, (db) => {
239
+ return withImmediateTransaction(db, () => {
240
+ if (!input.force) {
241
+ const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
242
+ if (skip)
243
+ return skip;
244
+ }
245
+ const created = nowIso(ctx);
246
+ // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
247
+ // is dropped silently — we never store NaN, Infinity, or out-of-range values.
248
+ // Callers that mis-report confidence should not poison the auto-accept gate.
249
+ const sanitizedConfidence = typeof input.confidence === "number" &&
250
+ Number.isFinite(input.confidence) &&
251
+ input.confidence >= 0 &&
252
+ input.confidence <= 1
253
+ ? input.confidence
254
+ : undefined;
255
+ const proposal = {
256
+ id: newId(ctx),
257
+ ref: normalizedRef,
258
+ status: "pending",
259
+ source: input.source,
260
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
261
+ createdAt: created,
262
+ updatedAt: created,
263
+ payload: {
264
+ content: input.payload.content,
265
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
266
+ },
267
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
268
+ // Attribution tagging: persist the eligibility lane so it survives to
269
+ // accept/reject/revert time. See EligibilitySource.
270
+ ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
271
+ };
272
+ upsertProposal(db, proposal, stashDir);
273
+ return proposal;
274
+ });
275
+ });
276
+ }
277
+ /**
278
+ * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
279
+ * skip record when a guard fires, or undefined when the create may proceed.
280
+ */
281
+ function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
282
+ const newHash = contentHash(input.payload.content);
283
+ const nowMs = (ctx?.now ?? Date.now)();
284
+ const cooldownMs = cooldownMsForSource(input.source);
285
+ // Scan pending proposals for ref+source matches.
286
+ const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
287
+ if (pending.length > 0) {
288
+ // Check for identical content hash first (silent skip).
289
+ const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
290
+ if (hashMatch) {
291
+ return {
292
+ skipped: true,
293
+ reason: "content_hash_match",
294
+ message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
295
+ existingProposalId: hashMatch.id,
296
+ };
297
+ }
298
+ // Duplicate pending for same ref+source (different content).
299
+ const firstPending = pending[0];
300
+ return {
301
+ skipped: true,
302
+ reason: "duplicate_pending",
303
+ message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
304
+ existingProposalId: firstPending?.id,
305
+ };
306
+ }
307
+ // Check cooldown against recently rejected proposals.
308
+ const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
309
+ .filter((p) => p.source === input.source)
310
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
311
+ const mostRecent = rejected[0];
312
+ if (mostRecent !== undefined) {
313
+ // Check content hash against recently rejected.
314
+ if (contentHash(mostRecent.payload.content) === newHash) {
315
+ return {
316
+ skipped: true,
317
+ reason: "content_hash_match",
318
+ message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
319
+ existingProposalId: mostRecent.id,
320
+ };
321
+ }
322
+ // Check cooldown window.
323
+ const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
324
+ if (nowMs - rejectedAt < cooldownMs) {
325
+ const cooldownDays = cooldownMs / MS_PER_DAY;
326
+ const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
327
+ return {
328
+ skipped: true,
329
+ reason: "cooldown",
330
+ message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
331
+ `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
332
+ existingProposalId: mostRecent.id,
333
+ };
334
+ }
335
+ }
336
+ return undefined;
337
+ }
338
+ /**
339
+ * List proposals for one stash. By default returns only the live (pending)
340
+ * queue; pass `{ includeArchive: true }` to include accepted / rejected /
341
+ * reverted entries as well.
342
+ */
343
+ export function listProposals(stashDir, options = {}, ctx) {
344
+ return withProposalsDb(stashDir, ctx, (db) => {
345
+ // Without includeArchive, only the live queue is visible — an explicit
346
+ // non-pending status filter therefore matches nothing (mirrors the
347
+ // historical live-directory scan).
348
+ if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
349
+ return [];
350
+ }
351
+ const status = options.includeArchive ? options.status : "pending";
352
+ return listStateProposals(db, {
353
+ stashDir,
354
+ ...(status !== undefined ? { status } : {}),
355
+ ...(options.ref !== undefined ? { ref: options.ref } : {}),
356
+ }).filter((p) => {
357
+ if (!options.type)
358
+ return true;
359
+ try {
360
+ return parseAssetRef(p.ref).type === options.type;
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ });
366
+ });
367
+ }
368
+ /**
369
+ * Look up a proposal by id (live or archived).
370
+ * Throws `NotFoundError` when no match exists in this stash.
371
+ */
372
+ export function getProposal(stashDir, id, ctx) {
373
+ return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
374
+ }
375
+ function requireProposal(db, stashDir, id) {
376
+ const proposal = getStateProposal(db, id, stashDir);
377
+ if (!proposal) {
378
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
379
+ }
380
+ return proposal;
381
+ }
382
+ /**
383
+ * Resolve a proposal by full UUID, UUID prefix, or asset ref.
384
+ *
385
+ * Resolution order:
386
+ * 1. Exact UUID match (existing behaviour).
387
+ * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
388
+ * that ref; falls back to archived if nothing is pending.
389
+ * 3. UUID prefix — matches any PENDING proposal whose id starts with the
390
+ * given string; throws if ambiguous.
391
+ */
392
+ export function resolveProposalId(stashDir, idOrRef, ctx) {
393
+ return withProposalsDb(stashDir, ctx, (db) => {
394
+ // 1. Exact UUID.
395
+ const exact = getStateProposal(db, idOrRef, stashDir);
396
+ if (exact)
397
+ return exact;
398
+ // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
399
+ // recent archived.
400
+ if (idOrRef.includes(":")) {
401
+ const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
402
+ const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
403
+ if (pending)
404
+ return pending;
405
+ const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
406
+ if (archived)
407
+ return archived;
408
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
409
+ }
410
+ // 3. UUID prefix (pending queue only).
411
+ const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
412
+ if (prefixMatches.length === 1)
413
+ return requireProposal(db, stashDir, prefixMatches[0]);
414
+ if (prefixMatches.length > 1) {
415
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
416
+ }
417
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
418
+ });
419
+ }
420
+ /**
421
+ * Archive a proposal: flip its status to `accepted` / `rejected`, bump
422
+ * `updatedAt`, and record the review block. Used by both accept and reject
423
+ * paths so the live queue only contains pending entries.
424
+ */
425
+ export function archiveProposal(stashDir, id, status, reason, ctx) {
426
+ return withProposalsDb(stashDir, ctx, (db) => {
427
+ return withImmediateTransaction(db, () => {
428
+ const existing = requireProposal(db, stashDir, id);
429
+ if (existing.status !== "pending") {
430
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
431
+ }
432
+ const decidedAt = nowIso(ctx);
433
+ const updated = {
434
+ ...existing,
435
+ status,
436
+ updatedAt: decidedAt,
437
+ review: {
438
+ outcome: status,
439
+ ...(reason !== undefined ? { reason } : {}),
440
+ decidedAt,
441
+ },
442
+ };
443
+ upsertProposal(db, updated, stashDir);
444
+ return updated;
445
+ });
446
+ });
447
+ }
448
+ /**
449
+ * Record an automated gate's decision onto a proposal (#577).
450
+ *
451
+ * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
452
+ * row so `akm proposal show` and `list` can explain why a proposal landed where
453
+ * it did. The decision is metadata about the adjudication, so this does NOT
454
+ * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
455
+ * and the accept / reject status flips are owned by {@link promoteProposal} /
456
+ * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
457
+ *
458
+ * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
459
+ * skipped silently rather than throwing, so a gate run never aborts mid-batch.
460
+ * Returns the updated proposal, or undefined when no matching row exists.
461
+ */
462
+ export function recordGateDecision(stashDir, id, decision, ctx) {
463
+ return withProposalsDb(stashDir, ctx, (db) => {
464
+ return withImmediateTransaction(db, () => {
465
+ const existing = getStateProposal(db, id, stashDir);
466
+ if (!existing || existing.status !== "pending")
467
+ return undefined;
468
+ const updated = {
469
+ ...existing,
470
+ gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
471
+ };
472
+ upsertProposal(db, updated, stashDir);
473
+ return updated;
474
+ });
475
+ });
476
+ }
477
+ /**
478
+ * Scan all pending proposals and reject those whose target asset no longer
479
+ * exists on disk across any of `sourceDirs`. Intended to run as a periodic
480
+ * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
481
+ * from accumulating stale reviewer work after large refactors or deletes.
482
+ *
483
+ * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
484
+ * Lessons, propose, distill, and consolidate proposals legitimately target
485
+ * assets that don't exist yet and must never be purged.
486
+ */
487
+ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
488
+ const t0 = Date.now();
489
+ const orphans = [];
490
+ const byType = {};
491
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
492
+ const reflectPending = pending.filter((p) => p.source === "reflect");
493
+ for (const p of reflectPending) {
494
+ let parsed;
495
+ try {
496
+ parsed = parseAssetRef(p.ref);
497
+ }
498
+ catch {
499
+ continue;
500
+ }
501
+ // Lessons are new-asset proposals by definition — they cannot be orphaned.
502
+ if (parsed.type === "lesson")
503
+ continue;
504
+ const spec = TYPE_DIRS[parsed.type];
505
+ if (!spec)
506
+ continue;
507
+ const exists = sourceDirs.some((root) => {
508
+ const typeRoot = path.join(root, spec);
509
+ const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
510
+ return fs.existsSync(candidate);
511
+ });
512
+ if (!exists) {
513
+ try {
514
+ archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
515
+ orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
516
+ byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
517
+ }
518
+ catch (err) {
519
+ // Best-effort — the purge is non-fatal. Log and continue.
520
+ warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
521
+ }
522
+ }
523
+ }
524
+ return {
525
+ checked: reflectPending.length,
526
+ rejected: orphans.length,
527
+ durationMs: Date.now() - t0,
528
+ byType,
529
+ orphans,
530
+ };
531
+ }
532
+ /**
533
+ * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
534
+ * D6b / Phase 6B).
535
+ *
536
+ * Reviewer fatigue and queue rot are the dominant failure modes of any
537
+ * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
538
+ * proposals that have aged past the retention window are very rarely accepted
539
+ * — the reviewer either intentionally declined to act on them, or the asset
540
+ * they target has drifted enough that the proposal is no longer relevant.
541
+ * Auto-expiring them keeps the live queue focused on actionable work; the
542
+ * archive preserves the full audit trail.
543
+ *
544
+ * Each expired proposal is archived with status `rejected` and reason
545
+ * `"expired: no action within retention window"`. A `proposal_expired` event
546
+ * is appended for each expired proposal so downstream observability (events
547
+ * dashboards, source-acceptance-rate aggregations) can see expiry separately
548
+ * from explicit rejections.
549
+ *
550
+ * Idempotent: a second call within the same retention window finds nothing
551
+ * to expire (the archived entries are no longer in the pending queue).
552
+ */
553
+ export function expireStaleProposals(stashDir, config, ctx) {
554
+ const t0 = Date.now();
555
+ const retentionDays = config.archiveRetentionDays ?? 90;
556
+ const expiredProposals = [];
557
+ // retentionDays === 0 disables TTL cleanup globally (mirrors how
558
+ // consolidate.ts interprets the same config value).
559
+ if (retentionDays <= 0) {
560
+ return {
561
+ checked: 0,
562
+ expired: 0,
563
+ durationMs: Date.now() - t0,
564
+ retentionDays,
565
+ expiredProposals,
566
+ };
567
+ }
568
+ const retentionMs = retentionDays * MS_PER_DAY;
569
+ const nowMs = (ctx?.now ?? Date.now)();
570
+ const pending = listProposals(stashDir, { status: "pending" }, ctx);
571
+ for (const p of pending) {
572
+ const createdMs = new Date(p.createdAt).getTime();
573
+ if (!Number.isFinite(createdMs))
574
+ continue;
575
+ const ageMs = nowMs - createdMs;
576
+ if (ageMs < retentionMs)
577
+ continue;
578
+ try {
579
+ archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
580
+ const ageDays = Math.floor(ageMs / MS_PER_DAY);
581
+ expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
582
+ appendEvent({
583
+ eventType: "proposal_expired",
584
+ ref: p.ref,
585
+ metadata: {
586
+ proposalId: p.id,
587
+ source: p.source,
588
+ ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
589
+ ageDays,
590
+ retentionDays,
591
+ },
592
+ });
593
+ }
594
+ catch (err) {
595
+ // Best-effort — a single failure must not block the pass.
596
+ warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
597
+ }
598
+ }
599
+ return {
600
+ checked: pending.length,
601
+ expired: expiredProposals.length,
602
+ durationMs: Date.now() - t0,
603
+ retentionDays,
604
+ expiredProposals,
605
+ };
606
+ }
607
+ /**
608
+ * Validate a proposal, then promote it through the canonical
609
+ * {@link writeAssetToSource} dispatch (the single place that branches on
610
+ * `source.kind`). On success the proposal is archived with status `accepted`.
611
+ * Validation failures throw a `UsageError` carrying every finding so the CLI
612
+ * can render a single clear error envelope.
613
+ *
614
+ * Phase 6C: when the target asset already exists at the resolved write path,
615
+ * its prior content is captured BEFORE the write and stored on the archived
616
+ * proposal record (`backupContent`) so `akm proposal revert` can restore it.
617
+ * Genuinely-new assets carry no backup.
618
+ */
619
+ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
620
+ const proposal = getProposal(stashDir, id, ctx);
621
+ if (proposal.status !== "pending") {
622
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
623
+ }
624
+ // Attempt bounded auto-repair of mechanically-fixable structural defects
625
+ // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
626
+ // BEFORE running validation. If the repair produces valid content, we
627
+ // promote the repaired version; if validation still fails, the original
628
+ // error path throws as before. The repair is content-preserving and
629
+ // deterministic — it never invents text.
630
+ const repairedContent = repairProposalContent(proposal.payload.content);
631
+ const proposalToValidate = repairedContent !== proposal.payload.content
632
+ ? { ...proposal, payload: { ...proposal.payload, content: repairedContent } }
633
+ : proposal;
634
+ const report = validateProposal(proposalToValidate);
635
+ if (!report.ok) {
636
+ const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
637
+ throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
638
+ }
639
+ // Use the (possibly repaired) payload for the promotion write. Persist the
640
+ // repaired content back onto the DB row so the audit trail reflects the
641
+ // final promoted payload (not the defective original).
642
+ if (repairedContent !== proposal.payload.content) {
643
+ withProposalsDb(stashDir, ctx, (db) => {
644
+ const updated = { ...proposal, payload: { ...proposal.payload, content: repairedContent } };
645
+ upsertProposal(db, updated, stashDir);
646
+ });
647
+ }
648
+ const ref = parseAssetRef(proposalToValidate.ref);
649
+ if (!TYPE_DIRS[ref.type]) {
650
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
651
+ }
652
+ const target = resolveWriteTarget(config, options.target);
653
+ // Phase 6C: capture the prior content (if any) BEFORE writing the new
654
+ // asset. We use the resolved write target to compute the exact path the
655
+ // asset would land at — same resolver `writeAssetToSource` uses — so the
656
+ // backup always mirrors what would be overwritten.
657
+ let backupContent;
658
+ try {
659
+ const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
660
+ if (targetFilePath && fs.existsSync(targetFilePath)) {
661
+ backupContent = fs.readFileSync(targetFilePath, "utf8");
662
+ }
663
+ }
664
+ catch (err) {
665
+ // Backup capture is best-effort. A failure here must not block promotion
666
+ // (the user explicitly asked to accept); we surface a warning so the
667
+ // missing-revert path is visible.
668
+ warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
669
+ }
670
+ const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
671
+ // 0.9.0 (issue #507): single batch commit at the write boundary for git
672
+ // targets. No-op for filesystem/primary-stash targets.
673
+ commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
674
+ const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
675
+ // Persist the backup content on the archived proposal record so the revert
676
+ // flow can restore the prior asset state.
677
+ if (backupContent !== undefined) {
678
+ const withBackup = { ...archived, backupContent };
679
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
680
+ return { proposal: withBackup, assetPath: written.path, ref: written.ref };
681
+ }
682
+ return { proposal: archived, assetPath: written.path, ref: written.ref };
683
+ }
684
+ /**
685
+ * Restore the prior content of an accepted proposal from the backup captured
686
+ * at promotion time (Advantage D6c / Phase 6C).
687
+ *
688
+ * Pre-conditions:
689
+ * - `id` resolves to a proposal with `status === "accepted"`.
690
+ * - The proposal carries `backupContent` (captured by promoteProposal when
691
+ * the target asset existed before the write).
692
+ *
693
+ * On success:
694
+ * - The backup content is written back through {@link writeAssetToSource},
695
+ * so the canonical write-dispatch invariant is preserved.
696
+ * - The proposal record is updated to `status: "reverted"`.
697
+ * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
698
+ * `promoted` / `rejected` are emitted by the CLI command, not the core).
699
+ *
700
+ * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
701
+ * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
702
+ * wrapper.
703
+ */
704
+ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
705
+ const proposal = getProposal(stashDir, id, ctx);
706
+ if (proposal.status !== "accepted") {
707
+ throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
708
+ }
709
+ if (proposal.backupContent === undefined) {
710
+ 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.");
711
+ }
712
+ const ref = parseAssetRef(proposal.ref);
713
+ if (!TYPE_DIRS[ref.type]) {
714
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
715
+ }
716
+ const target = resolveWriteTarget(config, options.target);
717
+ const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
718
+ // 0.9.0 (issue #507): single batch commit at the write boundary for git
719
+ // targets. No-op for filesystem/primary-stash targets.
720
+ commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
721
+ // Update the proposal record to status: "reverted" and bump updatedAt +
722
+ // review so the audit trail reflects the second decision.
723
+ const now = nowIso(ctx);
724
+ const reverted = {
725
+ ...proposal,
726
+ status: "reverted",
727
+ updatedAt: now,
728
+ review: {
729
+ outcome: "rejected",
730
+ reason: "reverted: prior content restored from backup",
731
+ decidedAt: now,
732
+ },
733
+ };
734
+ withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
735
+ return { proposal: reverted, assetPath: written.path, ref: written.ref };
736
+ }
737
+ /**
738
+ * Compute a diff between a proposal payload and the existing on-disk asset.
739
+ * Uses {@link resolveWriteTarget} to find where the asset would land — so the
740
+ * diff matches exactly what `accept` will write. Falls back to "new asset"
741
+ * when no asset is currently materialised at the target ref.
742
+ */
743
+ export function diffProposal(stashDir, config, id, options = {}, ctx) {
744
+ const proposal = getProposal(stashDir, id, ctx);
745
+ const ref = parseAssetRef(proposal.ref);
746
+ let targetPath;
747
+ let existing = null;
748
+ try {
749
+ const target = resolveWriteTarget(config, options.target);
750
+ targetPath = resolveAssetFilePathSafe(target.source, ref);
751
+ if (targetPath && fs.existsSync(targetPath)) {
752
+ existing = fs.readFileSync(targetPath, "utf8");
753
+ }
754
+ }
755
+ catch {
756
+ // No writable target configured — still return a "new asset" diff so
757
+ // callers can see the proposed payload without erroring out.
758
+ }
759
+ const proposed = proposal.payload.content;
760
+ if (existing === null) {
761
+ return {
762
+ existing: null,
763
+ proposed,
764
+ unified: formatNewAssetDiff(proposal.ref, proposed),
765
+ isNew: true,
766
+ ...(targetPath ? { targetPath } : {}),
767
+ };
768
+ }
769
+ return {
770
+ existing,
771
+ proposed,
772
+ unified: formatUnifiedDiff(existing, proposed, proposal.ref),
773
+ isNew: false,
774
+ ...(targetPath ? { targetPath } : {}),
775
+ };
776
+ }
777
+ function resolveAssetFilePathSafe(source, ref) {
778
+ const typeDir = TYPE_DIRS[ref.type];
779
+ if (!typeDir)
780
+ return undefined;
781
+ const typeRoot = path.join(source.path, typeDir);
782
+ try {
783
+ return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
784
+ }
785
+ catch {
786
+ return undefined;
787
+ }
788
+ }
789
+ /**
790
+ * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
791
+ * dependency just for this — proposals diffs are usually small (a single
792
+ * lesson / skill file), so the LCS-free greedy renderer below is plenty for
793
+ * humans to review. The output mirrors `git diff --no-index` for the first
794
+ * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
795
+ * a full LCS table.
796
+ */
797
+ export function formatUnifiedDiff(left, right, label) {
798
+ if (left === right)
799
+ return "";
800
+ const leftLines = left.split("\n");
801
+ const rightLines = right.split("\n");
802
+ const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
803
+ // Pad to the longer side so alignment is one-to-one. Real diff tools use
804
+ // LCS to align matching runs; we don't need that fidelity for a review
805
+ // surface — both halves are visible regardless.
806
+ const max = Math.max(leftLines.length, rightLines.length);
807
+ lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
808
+ for (let i = 0; i < max; i += 1) {
809
+ const l = leftLines[i];
810
+ const r = rightLines[i];
811
+ if (l === r && l !== undefined) {
812
+ lines.push(` ${l}`);
813
+ continue;
814
+ }
815
+ if (l !== undefined)
816
+ lines.push(`-${l}`);
817
+ if (r !== undefined)
818
+ lines.push(`+${r}`);
819
+ }
820
+ return lines.join("\n");
821
+ }
822
+ function formatNewAssetDiff(ref, content) {
823
+ const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
824
+ lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
825
+ for (const line of content.split("\n")) {
826
+ lines.push(`+${line}`);
827
+ }
828
+ return lines.join("\n");
829
+ }