akm-cli 0.8.0-rc1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +93 -3
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2162 -1258
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +20 -12
  12. package/dist/commands/agent-support.js +11 -5
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +129 -517
  15. package/dist/commands/consolidate.js +1533 -144
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +5 -3
  19. package/dist/commands/distill.js +906 -100
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +3 -0
  22. package/dist/commands/events.js +3 -0
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +260 -5
  28. package/dist/commands/health.js +977 -51
  29. package/dist/commands/help/help-accept.md +6 -3
  30. package/dist/commands/help/help-improve.md +36 -8
  31. package/dist/commands/help/help-proposals.md +7 -4
  32. package/dist/commands/help/help-reject.md +5 -2
  33. package/dist/commands/history.js +51 -16
  34. package/dist/commands/improve-auto-accept.js +97 -0
  35. package/dist/commands/improve-cli.js +236 -0
  36. package/dist/commands/improve-profiles.js +184 -0
  37. package/dist/commands/improve-result-file.js +167 -0
  38. package/dist/commands/improve.js +1725 -332
  39. package/dist/commands/info.js +3 -0
  40. package/dist/commands/init.js +49 -1
  41. package/dist/commands/installed-stashes.js +6 -23
  42. package/dist/commands/knowledge.js +3 -0
  43. package/dist/commands/lint/agent-linter.js +3 -0
  44. package/dist/commands/lint/base-linter.js +233 -5
  45. package/dist/commands/lint/command-linter.js +3 -0
  46. package/dist/commands/lint/default-linter.js +3 -0
  47. package/dist/commands/lint/env-key-rules.js +154 -0
  48. package/dist/commands/lint/index.js +92 -3
  49. package/dist/commands/lint/knowledge-linter.js +3 -0
  50. package/dist/commands/lint/markdown-insertion.js +343 -0
  51. package/dist/commands/lint/memory-linter.js +3 -0
  52. package/dist/commands/lint/registry.js +3 -0
  53. package/dist/commands/lint/skill-linter.js +3 -0
  54. package/dist/commands/lint/task-linter.js +15 -12
  55. package/dist/commands/lint/types.js +3 -0
  56. package/dist/commands/lint/workflow-linter.js +3 -0
  57. package/dist/commands/lint.js +3 -0
  58. package/dist/commands/migration-help.js +5 -2
  59. package/dist/commands/proposal-drain-policies.js +128 -0
  60. package/dist/commands/proposal-drain.js +477 -0
  61. package/dist/commands/proposal.js +60 -6
  62. package/dist/commands/propose.js +24 -19
  63. package/dist/commands/reflect.js +1004 -94
  64. package/dist/commands/registry-cli.js +150 -0
  65. package/dist/commands/registry-search.js +3 -0
  66. package/dist/commands/remember-cli.js +257 -0
  67. package/dist/commands/remember.js +15 -6
  68. package/dist/commands/schema-repair.js +88 -15
  69. package/dist/commands/search.js +99 -14
  70. package/dist/commands/secret.js +173 -0
  71. package/dist/commands/self-update.js +3 -0
  72. package/dist/commands/show.js +32 -13
  73. package/dist/commands/source-add.js +7 -35
  74. package/dist/commands/source-clone.js +3 -0
  75. package/dist/commands/source-manage.js +3 -0
  76. package/dist/commands/tasks.js +161 -95
  77. package/dist/commands/url-checker.js +3 -0
  78. package/dist/core/action-contributors.js +3 -0
  79. package/dist/core/asset-ref.js +17 -2
  80. package/dist/core/asset-registry.js +9 -2
  81. package/dist/core/asset-serialize.js +88 -0
  82. package/dist/core/asset-spec.js +61 -5
  83. package/dist/core/common.js +93 -5
  84. package/dist/core/concurrent.js +3 -0
  85. package/dist/core/config-io.js +347 -0
  86. package/dist/core/config-migration.js +622 -0
  87. package/dist/core/config-schema.js +558 -0
  88. package/dist/core/config-sources.js +108 -0
  89. package/dist/core/config-types.js +4 -0
  90. package/dist/core/config-walker.js +337 -0
  91. package/dist/core/config.js +366 -1077
  92. package/dist/core/errors.js +42 -20
  93. package/dist/core/events.js +31 -25
  94. package/dist/core/file-lock.js +104 -0
  95. package/dist/core/frontmatter.js +75 -10
  96. package/dist/core/lesson-lint.js +3 -0
  97. package/dist/core/markdown.js +3 -0
  98. package/dist/core/memory-belief.js +62 -0
  99. package/dist/core/memory-contradiction-detect.js +274 -0
  100. package/dist/core/memory-improve.js +142 -14
  101. package/dist/core/parse.js +3 -0
  102. package/dist/core/paths.js +218 -50
  103. package/dist/core/proposal-quality-validators.js +380 -0
  104. package/dist/core/proposal-validators.js +11 -3
  105. package/dist/core/proposals.js +464 -5
  106. package/dist/core/state-db.js +349 -56
  107. package/dist/core/text-truncation.js +107 -0
  108. package/dist/core/time.js +3 -0
  109. package/dist/core/tty.js +59 -0
  110. package/dist/core/warn.js +7 -2
  111. package/dist/core/write-source.js +12 -0
  112. package/dist/indexer/db-backup.js +391 -0
  113. package/dist/indexer/db-search.js +136 -28
  114. package/dist/indexer/db.js +662 -166
  115. package/dist/indexer/ensure-index.js +3 -0
  116. package/dist/indexer/file-context.js +3 -0
  117. package/dist/indexer/graph-boost.js +162 -40
  118. package/dist/indexer/graph-db.js +241 -51
  119. package/dist/indexer/graph-dedup.js +3 -7
  120. package/dist/indexer/graph-extraction.js +242 -149
  121. package/dist/indexer/index-context.js +3 -9
  122. package/dist/indexer/indexer.js +84 -14
  123. package/dist/indexer/llm-cache.js +24 -19
  124. package/dist/indexer/manifest.js +3 -0
  125. package/dist/indexer/matchers.js +184 -11
  126. package/dist/indexer/memory-inference.js +94 -50
  127. package/dist/indexer/metadata-contributors.js +3 -0
  128. package/dist/indexer/metadata.js +114 -48
  129. package/dist/indexer/path-resolver.js +3 -0
  130. package/dist/indexer/project-context.js +192 -0
  131. package/dist/indexer/ranking-contributors.js +134 -7
  132. package/dist/indexer/ranking.js +8 -1
  133. package/dist/indexer/search-fields.js +5 -9
  134. package/dist/indexer/search-hit-enrichers.js +91 -2
  135. package/dist/indexer/search-source.js +20 -1
  136. package/dist/indexer/semantic-status.js +4 -1
  137. package/dist/indexer/staleness-detect.js +447 -0
  138. package/dist/indexer/usage-events.js +12 -9
  139. package/dist/indexer/walker.js +3 -0
  140. package/dist/integrations/agent/builders.js +135 -0
  141. package/dist/integrations/agent/config.js +121 -401
  142. package/dist/integrations/agent/detect.js +3 -0
  143. package/dist/integrations/agent/index.js +6 -14
  144. package/dist/integrations/agent/model-aliases.js +55 -0
  145. package/dist/integrations/agent/profiles.js +3 -0
  146. package/dist/integrations/agent/prompts.js +137 -8
  147. package/dist/integrations/agent/runner.js +208 -0
  148. package/dist/integrations/agent/sdk-runner.js +8 -2
  149. package/dist/integrations/agent/spawn.js +54 -14
  150. package/dist/integrations/github.js +3 -0
  151. package/dist/integrations/lockfile.js +22 -51
  152. package/dist/integrations/session-logs/index.js +4 -0
  153. package/dist/integrations/session-logs/inline-refs.js +35 -0
  154. package/dist/integrations/session-logs/pre-filter.js +152 -0
  155. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  156. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  157. package/dist/integrations/session-logs/types.js +3 -0
  158. package/dist/llm/call-ai.js +14 -26
  159. package/dist/llm/client.js +16 -2
  160. package/dist/llm/embedder.js +20 -29
  161. package/dist/llm/embedders/cache.js +3 -7
  162. package/dist/llm/embedders/local.js +42 -1
  163. package/dist/llm/embedders/remote.js +20 -8
  164. package/dist/llm/embedders/types.js +3 -7
  165. package/dist/llm/feature-gate.js +92 -56
  166. package/dist/llm/graph-extract.js +401 -30
  167. package/dist/llm/index-passes.js +44 -29
  168. package/dist/llm/memory-infer.js +30 -2
  169. package/dist/llm/metadata-enhance.js +3 -7
  170. package/dist/llm/prompts/extract-session.md +80 -0
  171. package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
  172. package/dist/output/cli-hints-full.md +60 -32
  173. package/dist/output/cli-hints-short.md +10 -7
  174. package/dist/output/cli-hints.js +5 -2
  175. package/dist/output/context.js +60 -8
  176. package/dist/output/renderers.js +170 -194
  177. package/dist/output/shapes/curate.js +56 -0
  178. package/dist/output/shapes/distill.js +10 -0
  179. package/dist/output/shapes/env-list.js +19 -0
  180. package/dist/output/shapes/events.js +11 -0
  181. package/dist/output/shapes/helpers.js +424 -0
  182. package/dist/output/shapes/history.js +7 -0
  183. package/dist/output/shapes/passthrough.js +105 -0
  184. package/dist/output/shapes/proposal-accept.js +7 -0
  185. package/dist/output/shapes/proposal-diff.js +7 -0
  186. package/dist/output/shapes/proposal-list.js +7 -0
  187. package/dist/output/shapes/proposal-producer.js +11 -0
  188. package/dist/output/shapes/proposal-reject.js +7 -0
  189. package/dist/output/shapes/proposal-show.js +7 -0
  190. package/dist/output/shapes/registry-search.js +6 -0
  191. package/dist/output/shapes/registry.js +30 -0
  192. package/dist/output/shapes/search.js +6 -0
  193. package/dist/output/shapes/secret-list.js +19 -0
  194. package/dist/output/shapes/show.js +6 -0
  195. package/dist/output/shapes/vault-list.js +19 -0
  196. package/dist/output/shapes.js +51 -549
  197. package/dist/output/text/add.js +6 -0
  198. package/dist/output/text/clone.js +6 -0
  199. package/dist/output/text/config.js +6 -0
  200. package/dist/output/text/curate.js +6 -0
  201. package/dist/output/text/distill.js +7 -0
  202. package/dist/output/text/enable-disable.js +7 -0
  203. package/dist/output/text/events.js +10 -0
  204. package/dist/output/text/feedback.js +6 -0
  205. package/dist/output/text/helpers.js +1059 -0
  206. package/dist/output/text/history.js +7 -0
  207. package/dist/output/text/import.js +6 -0
  208. package/dist/output/text/index.js +6 -0
  209. package/dist/output/text/info.js +6 -0
  210. package/dist/output/text/init.js +6 -0
  211. package/dist/output/text/list.js +6 -0
  212. package/dist/output/text/proposal-producer.js +8 -0
  213. package/dist/output/text/proposal.js +12 -0
  214. package/dist/output/text/registry-commands.js +11 -0
  215. package/dist/output/text/registry.js +30 -0
  216. package/dist/output/text/remember.js +6 -0
  217. package/dist/output/text/remove.js +6 -0
  218. package/dist/output/text/save.js +6 -0
  219. package/dist/output/text/search.js +6 -0
  220. package/dist/output/text/show.js +6 -0
  221. package/dist/output/text/update.js +6 -0
  222. package/dist/output/text/upgrade.js +6 -0
  223. package/dist/output/text/vault.js +16 -0
  224. package/dist/output/text/wiki.js +15 -0
  225. package/dist/output/text/workflow.js +14 -0
  226. package/dist/output/text.js +44 -1329
  227. package/dist/registry/build-index.js +3 -0
  228. package/dist/registry/create-provider-registry.js +3 -0
  229. package/dist/registry/factory.js +4 -1
  230. package/dist/registry/origin-resolve.js +3 -0
  231. package/dist/registry/providers/index.js +3 -0
  232. package/dist/registry/providers/skills-sh.js +11 -2
  233. package/dist/registry/providers/static-index.js +10 -1
  234. package/dist/registry/providers/types.js +3 -24
  235. package/dist/registry/resolve.js +11 -16
  236. package/dist/registry/types.js +3 -0
  237. package/dist/scripts/migrate-storage.js +17767 -0
  238. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  239. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  240. package/dist/setup/detect.js +3 -0
  241. package/dist/setup/ripgrep-install.js +3 -0
  242. package/dist/setup/ripgrep-resolve.js +3 -0
  243. package/dist/setup/setup.js +306 -67
  244. package/dist/setup/steps.js +3 -15
  245. package/dist/sources/include.js +3 -0
  246. package/dist/sources/provider-factory.js +3 -11
  247. package/dist/sources/provider.js +3 -20
  248. package/dist/sources/providers/filesystem.js +19 -23
  249. package/dist/sources/providers/git.js +171 -21
  250. package/dist/sources/providers/index.js +3 -0
  251. package/dist/sources/providers/install-types.js +3 -13
  252. package/dist/sources/providers/npm.js +3 -4
  253. package/dist/sources/providers/provider-utils.js +3 -0
  254. package/dist/sources/providers/sync-from-ref.js +3 -11
  255. package/dist/sources/providers/tar-utils.js +3 -0
  256. package/dist/sources/providers/website.js +18 -22
  257. package/dist/sources/resolve.js +3 -0
  258. package/dist/sources/types.js +3 -0
  259. package/dist/sources/website-ingest.js +3 -0
  260. package/dist/tasks/backends/cron.js +3 -0
  261. package/dist/tasks/backends/exec-utils.js +3 -0
  262. package/dist/tasks/backends/index.js +3 -11
  263. package/dist/tasks/backends/launchd.js +3 -0
  264. package/dist/tasks/backends/schtasks.js +3 -0
  265. package/dist/tasks/parser.js +51 -38
  266. package/dist/tasks/resolveAkmBin.js +3 -0
  267. package/dist/tasks/runner.js +35 -9
  268. package/dist/tasks/schedule.js +20 -1
  269. package/dist/tasks/schema.js +5 -3
  270. package/dist/tasks/validator.js +6 -3
  271. package/dist/version.js +3 -0
  272. package/dist/wiki/wiki-templates.js +3 -0
  273. package/dist/wiki/wiki.js +3 -0
  274. package/dist/workflows/authoring.js +3 -0
  275. package/dist/workflows/cli.js +3 -0
  276. package/dist/workflows/db.js +140 -10
  277. package/dist/workflows/document-cache.js +3 -10
  278. package/dist/workflows/parser.js +3 -0
  279. package/dist/workflows/renderer.js +3 -0
  280. package/dist/workflows/runs.js +18 -1
  281. package/dist/workflows/schema.js +3 -0
  282. package/dist/workflows/scope-key.js +3 -0
  283. package/dist/workflows/validator.js +5 -9
  284. package/docs/README.md +7 -2
  285. package/docs/data-and-telemetry.md +225 -0
  286. package/docs/migration/release-notes/0.7.5.md +2 -2
  287. package/docs/migration/release-notes/0.8.0.md +57 -5
  288. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  289. package/package.json +28 -11
  290. package/.github/LICENSE +0 -374
  291. package/dist/commands/install-audit.js +0 -385
  292. package/dist/commands/vault.js +0 -307
  293. package/dist/indexer/match-contributors.js +0 -141
  294. package/dist/integrations/agent/pipeline.js +0 -39
  295. package/dist/integrations/agent/runners.js +0 -31
@@ -1,3 +1,6 @@
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/.
1
4
  /**
2
5
  * Proposal substrate (#225).
3
6
  *
@@ -33,14 +36,101 @@
33
36
  * that bypasses `writeAssetToSource` for "stash-adjacent" durable state. See
34
37
  * CLAUDE.md ("Writes" section) for the contract.
35
38
  */
36
- import { randomUUID } from "node:crypto";
39
+ import { createHash, randomUUID } from "node:crypto";
37
40
  import fs from "node:fs";
38
41
  import path from "node:path";
39
42
  import { makeAssetRef, parseAssetRef } from "./asset-ref";
40
43
  import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
41
44
  import { NotFoundError, UsageError } from "./errors";
45
+ import { appendEvent } from "./events";
42
46
  import { runProposalValidators } from "./proposal-validators";
47
+ import { warn } from "./warn";
43
48
  import { resolveWriteTarget, writeAssetToSource } from "./write-source";
49
+ // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
50
+ /**
51
+ * Curated allow-list of valid `source` values for proposals (F-4 / #385).
52
+ *
53
+ * Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
54
+ * sources for meaningful aggregation. Accept-rate-per-source is the core
55
+ * self-measurement metric for recursive self-improvement: if reflect proposals
56
+ * are accepted at 20% and distill proposals at 60%, that guides resource
57
+ * allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
58
+ *
59
+ * Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
60
+ * `sourceRun` field for full PROV-DM traceability.
61
+ */
62
+ export const PROPOSAL_SOURCES = [
63
+ // Automated sources — require sourceRun for traceability.
64
+ "reflect",
65
+ "distill",
66
+ "consolidate",
67
+ "extract",
68
+ "improve",
69
+ // Semi-automated / tool-driven.
70
+ "feedback",
71
+ // Human-initiated / CLI-driven.
72
+ "propose",
73
+ "remember",
74
+ "import",
75
+ // Internal / system.
76
+ "distill_quality_rejected",
77
+ "schema-repair",
78
+ ];
79
+ /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
80
+ export const AUTOMATED_PROPOSAL_SOURCES = [
81
+ "reflect",
82
+ "distill",
83
+ "consolidate",
84
+ "extract",
85
+ "improve",
86
+ "schema-repair",
87
+ ];
88
+ /**
89
+ * Check whether a string is a valid {@link ProposalSource}.
90
+ * Unknown source values are accepted with a runtime warning rather than a hard
91
+ * error, to allow extensions without breaking existing callers.
92
+ */
93
+ export function isValidProposalSource(source) {
94
+ return PROPOSAL_SOURCES.includes(source);
95
+ }
96
+ /**
97
+ * Check whether a source value is an automated source requiring `sourceRun`.
98
+ */
99
+ export function isAutomatedProposalSource(source) {
100
+ return AUTOMATED_PROPOSAL_SOURCES.includes(source);
101
+ }
102
+ /** Type guard: true when createProposal returned a skipped record. */
103
+ export function isProposalSkipped(result) {
104
+ return result.skipped === true;
105
+ }
106
+ // ── Dedup / cooldown constants ───────────────────────────────────────────────
107
+ const MS_PER_DAY = 86_400_000;
108
+ /**
109
+ * Post-rejection cooldown windows by source. After a proposal is rejected,
110
+ * `createProposal` silently skips new proposals for the same `ref+source`
111
+ * until the window expires (unless `force: true` is passed).
112
+ *
113
+ * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
114
+ * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
115
+ * prevent nightly improve runs from re-flooding the queue with near-identical
116
+ * proposals the reviewer just declined.
117
+ *
118
+ * - reflect: 14 days (agent-based; slower feedback loops)
119
+ * - distill: 30 days (LLM-based; even more prone to regeneration loops)
120
+ * - default: 7 days (conservative fallback for other sources)
121
+ */
122
+ const COOLDOWN_MS = {
123
+ reflect: 14 * MS_PER_DAY,
124
+ distill: 30 * MS_PER_DAY,
125
+ };
126
+ const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
127
+ function cooldownMsForSource(source) {
128
+ return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
129
+ }
130
+ /** Compute a stable SHA-256 hex digest of a proposal's content string. */
131
+ function contentHash(content) {
132
+ return createHash("sha256").update(content, "utf8").digest("hex");
133
+ }
44
134
  // ── Path helpers ────────────────────────────────────────────────────────────
45
135
  /**
46
136
  * Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
@@ -93,14 +183,140 @@ function writeProposalFile(filePath, proposal) {
93
183
  /**
94
184
  * Create a new pending proposal. The id is a stable random UUID, so two
95
185
  * proposals with the same `ref` never collide on disk.
186
+ *
187
+ * **Dedup / cooldown guard** (F-2 / #363):
188
+ *
189
+ * Before writing, this function checks:
190
+ * 1. `duplicate_pending` — a pending proposal already exists for the same
191
+ * `ref+source`. Pass `input.force = true` to bypass.
192
+ * 2. `content_hash_match` — an identical content hash is already pending or
193
+ * was recently rejected for this `ref+source`. Bypass with `force: true`.
194
+ * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
195
+ * source-specific cooldown window (reflect: 14 d, distill: 30 d,
196
+ * others: 7 d). Bypass with `force: true`.
197
+ *
198
+ * When a guard fires the function returns a `CreateProposalSkipped` record
199
+ * instead of writing to disk. Use {@link isProposalSkipped} to detect it.
96
200
  */
97
201
  export function createProposal(stashDir, input, ctx) {
98
- // Validate the ref up front so callers get a clear error instead of a
99
- // surprise during `accept`. This also normalises the ref string.
100
- const parsedRef = parseAssetRef(input.ref);
202
+ // F-4 / #385: Validate source against the allow-list. Unknown values are
203
+ // warned (not rejected) for backward compatibility extension callers
204
+ // that pass custom source strings must not break.
205
+ if (!isValidProposalSource(input.source)) {
206
+ warn(`[proposal] Unknown source "${input.source}". ` +
207
+ `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
208
+ "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
209
+ }
210
+ else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
211
+ // Advisory warning: automated sources should include sourceRun for PROV-DM
212
+ // traceability. This is not a hard error to avoid breaking existing callers.
213
+ warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
214
+ "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
215
+ }
216
+ // Deterministic input validation. Reject obviously-invalid proposals at
217
+ // the source rather than letting them enter the queue and waste reviewer
218
+ // time. Each rejection emits `proposal_creation_rejected` with a typed
219
+ // reason so we can see *which* check is firing in the event stream.
220
+ const rejectProposal = (reason, message) => {
221
+ appendEvent({
222
+ eventType: "proposal_creation_rejected",
223
+ ref: input.ref,
224
+ metadata: { source: input.source, reason },
225
+ });
226
+ throw new UsageError(message, "INVALID_PROPOSAL");
227
+ };
228
+ let parsedRef;
229
+ try {
230
+ parsedRef = parseAssetRef(input.ref);
231
+ }
232
+ catch (err) {
233
+ return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ if (!TYPE_DIRS[parsedRef.type]) {
236
+ return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
237
+ }
238
+ if (!input.payload.content.trim()) {
239
+ return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
240
+ }
241
+ // Description check is only enforced for `consolidate` source — that's the
242
+ // automated pipeline that historically produced proposals with missing or
243
+ // malformed frontmatter, polluting the queue with hundreds of unusable
244
+ // entries. Reflect / distill / propose proposals have varied legitimate
245
+ // shapes and should not be rejected here for missing description.
246
+ if (input.source === "consolidate") {
247
+ const desc = input.payload.frontmatter?.description;
248
+ if (typeof desc !== "string" || desc.trim() === "") {
249
+ return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
250
+ }
251
+ }
101
252
  const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
253
+ if (!input.force) {
254
+ const newHash = contentHash(input.payload.content);
255
+ const nowMs = (ctx?.now ?? Date.now)();
256
+ const cooldownMs = cooldownMsForSource(input.source);
257
+ // Scan pending proposals for ref+source matches.
258
+ const pending = listProposals(stashDir, { ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
259
+ if (pending.length > 0) {
260
+ // Check for identical content hash first (silent skip).
261
+ const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
262
+ if (hashMatch) {
263
+ return {
264
+ skipped: true,
265
+ reason: "content_hash_match",
266
+ message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
267
+ existingProposalId: hashMatch.id,
268
+ };
269
+ }
270
+ // Duplicate pending for same ref+source (different content).
271
+ const firstPending = pending[0];
272
+ return {
273
+ 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,
277
+ };
278
+ }
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
+ }
307
+ }
308
+ }
102
309
  const id = newId(ctx);
103
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;
104
320
  const proposal = {
105
321
  id,
106
322
  ref: normalizedRef,
@@ -113,6 +329,7 @@ export function createProposal(stashDir, input, ctx) {
113
329
  content: input.payload.content,
114
330
  ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
115
331
  },
332
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
116
333
  };
117
334
  writeProposalFile(proposalFile(stashDir, id, false), proposal);
118
335
  return proposal;
@@ -157,7 +374,7 @@ export function listProposals(stashDir, options = {}) {
157
374
  out.push({
158
375
  id: entry.name,
159
376
  ref: "unknown:unknown",
160
- status: "pending",
377
+ status: "rejected",
161
378
  source: "invalid",
162
379
  createdAt: "",
163
380
  updatedAt: "",
@@ -174,6 +391,18 @@ export function listProposals(stashDir, options = {}) {
174
391
  return out
175
392
  .filter((p) => (options.status ? p.status === options.status : true))
176
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
+ })
177
406
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
178
407
  }
179
408
  /**
@@ -286,6 +515,136 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
286
515
  writeProposalFile(proposalFile(stashDir, id, true), updated);
287
516
  return updated;
288
517
  }
518
+ /**
519
+ * Scan all pending proposals and reject those whose target asset no longer
520
+ * exists on disk across any of `sourceDirs`. Intended to run as a periodic
521
+ * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
522
+ * from accumulating stale reviewer work after large refactors or deletes.
523
+ *
524
+ * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
525
+ * Lessons, propose, distill, and consolidate proposals legitimately target
526
+ * assets that don't exist yet and must never be purged.
527
+ */
528
+ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
529
+ const t0 = Date.now();
530
+ const orphans = [];
531
+ const byType = {};
532
+ const pending = listProposals(stashDir, { status: "pending" });
533
+ const reflectPending = pending.filter((p) => p.source === "reflect");
534
+ for (const p of reflectPending) {
535
+ let parsed;
536
+ try {
537
+ parsed = parseAssetRef(p.ref);
538
+ }
539
+ catch {
540
+ continue;
541
+ }
542
+ // Lessons are new-asset proposals by definition — they cannot be orphaned.
543
+ if (parsed.type === "lesson")
544
+ continue;
545
+ const spec = TYPE_DIRS[parsed.type];
546
+ if (!spec)
547
+ continue;
548
+ const exists = sourceDirs.some((root) => {
549
+ const typeRoot = path.join(root, spec);
550
+ const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
551
+ return fs.existsSync(candidate);
552
+ });
553
+ if (!exists) {
554
+ try {
555
+ archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
556
+ orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
557
+ byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
558
+ }
559
+ catch (err) {
560
+ // Best-effort — the purge is non-fatal. Log and continue.
561
+ warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
562
+ }
563
+ }
564
+ }
565
+ return {
566
+ checked: reflectPending.length,
567
+ rejected: orphans.length,
568
+ durationMs: Date.now() - t0,
569
+ byType,
570
+ orphans,
571
+ };
572
+ }
573
+ /**
574
+ * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
575
+ * D6b / Phase 6B).
576
+ *
577
+ * Reviewer fatigue and queue rot are the dominant failure modes of any
578
+ * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
579
+ * proposals that have aged past the retention window are very rarely accepted
580
+ * — the reviewer either intentionally declined to act on them, or the asset
581
+ * they target has drifted enough that the proposal is no longer relevant.
582
+ * Auto-expiring them keeps the live queue focused on actionable work; the
583
+ * archive preserves the full audit trail.
584
+ *
585
+ * Each expired proposal is archived with status `rejected` and reason
586
+ * `"expired: no action within retention window"`. A `proposal_expired` event
587
+ * is appended for each expired proposal so downstream observability (events
588
+ * dashboards, source-acceptance-rate aggregations) can see expiry separately
589
+ * from explicit rejections.
590
+ *
591
+ * Idempotent: a second call within the same retention window finds nothing
592
+ * to expire (the archived entries are no longer in the pending queue).
593
+ */
594
+ export function expireStaleProposals(stashDir, config, ctx) {
595
+ const t0 = Date.now();
596
+ const retentionDays = config.archiveRetentionDays ?? 90;
597
+ const expiredProposals = [];
598
+ // retentionDays === 0 disables TTL cleanup globally (mirrors how
599
+ // consolidate.ts interprets the same config value).
600
+ if (retentionDays <= 0) {
601
+ return {
602
+ checked: 0,
603
+ expired: 0,
604
+ durationMs: Date.now() - t0,
605
+ retentionDays,
606
+ expiredProposals,
607
+ };
608
+ }
609
+ const retentionMs = retentionDays * MS_PER_DAY;
610
+ const nowMs = (ctx?.now ?? Date.now)();
611
+ const pending = listProposals(stashDir, { status: "pending" });
612
+ for (const p of pending) {
613
+ const createdMs = new Date(p.createdAt).getTime();
614
+ if (!Number.isFinite(createdMs))
615
+ continue;
616
+ const ageMs = nowMs - createdMs;
617
+ if (ageMs < retentionMs)
618
+ continue;
619
+ try {
620
+ archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
621
+ const ageDays = Math.floor(ageMs / MS_PER_DAY);
622
+ expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
623
+ appendEvent({
624
+ eventType: "proposal_expired",
625
+ ref: p.ref,
626
+ metadata: {
627
+ proposalId: p.id,
628
+ source: p.source,
629
+ ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
630
+ ageDays,
631
+ retentionDays,
632
+ },
633
+ });
634
+ }
635
+ catch (err) {
636
+ // Best-effort — a single failure must not block the pass.
637
+ warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
638
+ }
639
+ }
640
+ return {
641
+ checked: pending.length,
642
+ expired: expiredProposals.length,
643
+ durationMs: Date.now() - t0,
644
+ retentionDays,
645
+ expiredProposals,
646
+ };
647
+ }
289
648
  /**
290
649
  * Validate a proposal payload before promotion. Generic by default — any
291
650
  * proposal must parse cleanly and carry a non-empty body. Lessons get the
@@ -302,6 +661,12 @@ export function validateProposal(proposal) {
302
661
  * `source.kind`). On success the proposal directory is moved to the archive
303
662
  * with status `accepted`. Validation failures throw a `UsageError` carrying
304
663
  * every finding so the CLI can render a single clear error envelope.
664
+ *
665
+ * 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.
305
670
  */
306
671
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
307
672
  const proposal = getProposal(stashDir, id);
@@ -318,10 +683,104 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
318
683
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
319
684
  }
320
685
  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
688
+ // asset would land at — same resolver `writeAssetToSource` uses — so the
689
+ // backup always mirrors what would be overwritten.
690
+ let backupRelPath;
691
+ try {
692
+ const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
693
+ 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;
705
+ }
706
+ }
707
+ catch (err) {
708
+ // Backup capture is best-effort. A failure here must not block promotion
709
+ // (the user explicitly asked to accept); we surface a warning so the
710
+ // missing-revert path is visible.
711
+ warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
712
+ }
321
713
  const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
322
714
  const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
715
+ // Persist the backup path on the archived proposal record. archiveProposal
716
+ // moves the proposal dir into the archive subtree, so the backup file moves
717
+ // with it (the relative path stays valid).
718
+ if (backupRelPath) {
719
+ const archivedFile = proposalFile(stashDir, id, true);
720
+ const withBackup = { ...archived, backup: backupRelPath };
721
+ writeProposalFile(archivedFile, withBackup);
722
+ return { proposal: withBackup, assetPath: written.path, ref: written.ref };
723
+ }
323
724
  return { proposal: archived, assetPath: written.path, ref: written.ref };
324
725
  }
726
+ /**
727
+ * Restore the prior content of an accepted proposal from its captured backup
728
+ * (Advantage D6c / Phase 6C).
729
+ *
730
+ * Pre-conditions:
731
+ * - `id` resolves to a proposal with `status === "accepted"`.
732
+ * - The proposal carries a `backup` field pointing to a readable file under
733
+ * the proposal directory.
734
+ *
735
+ * On success:
736
+ * - The backup content is written back through {@link writeAssetToSource},
737
+ * so the canonical write-dispatch invariant is preserved.
738
+ * - The archived proposal record is updated to `status: "reverted"`.
739
+ * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
740
+ * `promoted` / `rejected` are emitted by the CLI command, not the core).
741
+ *
742
+ * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
743
+ * cleanly to exit codes — see `src/commands/proposal.ts` for the wrapper.
744
+ */
745
+ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
746
+ const proposal = getProposal(stashDir, id);
747
+ if (proposal.status !== "accepted") {
748
+ throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
749
+ }
750
+ if (!proposal.backup) {
751
+ 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.");
752
+ }
753
+ // The proposal directory has been moved to the archive subtree (archiveProposal
754
+ // runs at the end of promoteProposal). Reads must resolve against that path.
755
+ const proposalRoot = proposalDir(stashDir, id, true);
756
+ const backupAbsPath = path.join(proposalRoot, proposal.backup);
757
+ if (!fs.existsSync(backupAbsPath)) {
758
+ 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.`);
759
+ }
760
+ const backupContent = fs.readFileSync(backupAbsPath, "utf8");
761
+ const ref = parseAssetRef(proposal.ref);
762
+ if (!TYPE_DIRS[ref.type]) {
763
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
764
+ }
765
+ const target = resolveWriteTarget(config, options.target);
766
+ const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
767
+ // Update the archived proposal record to status: "reverted" and bump
768
+ // updatedAt + review so the audit trail reflects the second decision.
769
+ const archivedFile = proposalFile(stashDir, id, true);
770
+ const now = nowIso(ctx);
771
+ const reverted = {
772
+ ...proposal,
773
+ status: "reverted",
774
+ updatedAt: now,
775
+ review: {
776
+ outcome: "rejected",
777
+ reason: "reverted: prior content restored from backup",
778
+ decidedAt: now,
779
+ },
780
+ };
781
+ writeProposalFile(archivedFile, reverted);
782
+ return { proposal: reverted, assetPath: written.path, ref: written.ref };
783
+ }
325
784
  /**
326
785
  * Compute a diff between a proposal payload and the existing on-disk asset.
327
786
  * Uses {@link resolveWriteTarget} to find where the asset would land — so the