akm-cli 0.7.5 → 0.8.0-rc.6

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 (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  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 +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -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,15 +36,99 @@
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";
42
- import { parseFrontmatter } from "./frontmatter";
43
- import { lintLessonContent } from "./lesson-lint";
45
+ import { appendEvent } from "./events";
46
+ import { runProposalValidators } from "./proposal-validators";
47
+ import { warn } from "./warn";
44
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
+ "improve",
68
+ // Semi-automated / tool-driven.
69
+ "feedback",
70
+ // Human-initiated / CLI-driven.
71
+ "propose",
72
+ "remember",
73
+ "import",
74
+ // Internal / system.
75
+ "distill_quality_rejected",
76
+ "schema-repair",
77
+ ];
78
+ /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
79
+ export const AUTOMATED_PROPOSAL_SOURCES = [
80
+ "reflect",
81
+ "distill",
82
+ "consolidate",
83
+ "improve",
84
+ "schema-repair",
85
+ ];
86
+ /**
87
+ * Check whether a string is a valid {@link ProposalSource}.
88
+ * Unknown source values are accepted with a runtime warning rather than a hard
89
+ * error, to allow extensions without breaking existing callers.
90
+ */
91
+ export function isValidProposalSource(source) {
92
+ return PROPOSAL_SOURCES.includes(source);
93
+ }
94
+ /**
95
+ * Check whether a source value is an automated source requiring `sourceRun`.
96
+ */
97
+ export function isAutomatedProposalSource(source) {
98
+ return AUTOMATED_PROPOSAL_SOURCES.includes(source);
99
+ }
100
+ /** Type guard: true when createProposal returned a skipped record. */
101
+ export function isProposalSkipped(result) {
102
+ return result.skipped === true;
103
+ }
104
+ // ── Dedup / cooldown constants ───────────────────────────────────────────────
105
+ const MS_PER_DAY = 86_400_000;
106
+ /**
107
+ * Post-rejection cooldown windows by source. After a proposal is rejected,
108
+ * `createProposal` silently skips new proposals for the same `ref+source`
109
+ * until the window expires (unless `force: true` is passed).
110
+ *
111
+ * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
112
+ * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
113
+ * prevent nightly improve runs from re-flooding the queue with near-identical
114
+ * proposals the reviewer just declined.
115
+ *
116
+ * - reflect: 14 days (agent-based; slower feedback loops)
117
+ * - distill: 30 days (LLM-based; even more prone to regeneration loops)
118
+ * - default: 7 days (conservative fallback for other sources)
119
+ */
120
+ const COOLDOWN_MS = {
121
+ reflect: 14 * MS_PER_DAY,
122
+ distill: 30 * MS_PER_DAY,
123
+ };
124
+ const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
125
+ function cooldownMsForSource(source) {
126
+ return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
127
+ }
128
+ /** Compute a stable SHA-256 hex digest of a proposal's content string. */
129
+ function contentHash(content) {
130
+ return createHash("sha256").update(content, "utf8").digest("hex");
131
+ }
45
132
  // ── Path helpers ────────────────────────────────────────────────────────────
46
133
  /**
47
134
  * Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
@@ -94,14 +181,140 @@ function writeProposalFile(filePath, proposal) {
94
181
  /**
95
182
  * Create a new pending proposal. The id is a stable random UUID, so two
96
183
  * proposals with the same `ref` never collide on disk.
184
+ *
185
+ * **Dedup / cooldown guard** (F-2 / #363):
186
+ *
187
+ * Before writing, this function checks:
188
+ * 1. `duplicate_pending` — a pending proposal already exists for the same
189
+ * `ref+source`. Pass `input.force = true` to bypass.
190
+ * 2. `content_hash_match` — an identical content hash is already pending or
191
+ * was recently rejected for this `ref+source`. Bypass with `force: true`.
192
+ * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
193
+ * source-specific cooldown window (reflect: 14 d, distill: 30 d,
194
+ * others: 7 d). Bypass with `force: true`.
195
+ *
196
+ * When a guard fires the function returns a `CreateProposalSkipped` record
197
+ * instead of writing to disk. Use {@link isProposalSkipped} to detect it.
97
198
  */
98
199
  export function createProposal(stashDir, input, ctx) {
99
- // Validate the ref up front so callers get a clear error instead of a
100
- // surprise during `accept`. This also normalises the ref string.
101
- const parsedRef = parseAssetRef(input.ref);
200
+ // F-4 / #385: Validate source against the allow-list. Unknown values are
201
+ // warned (not rejected) for backward compatibility extension callers
202
+ // that pass custom source strings must not break.
203
+ if (!isValidProposalSource(input.source)) {
204
+ warn(`[proposal] Unknown source "${input.source}". ` +
205
+ `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
206
+ "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
207
+ }
208
+ else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
209
+ // Advisory warning: automated sources should include sourceRun for PROV-DM
210
+ // traceability. This is not a hard error to avoid breaking existing callers.
211
+ warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
212
+ "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
213
+ }
214
+ // Deterministic input validation. Reject obviously-invalid proposals at
215
+ // the source rather than letting them enter the queue and waste reviewer
216
+ // time. Each rejection emits `proposal_creation_rejected` with a typed
217
+ // reason so we can see *which* check is firing in the event stream.
218
+ const rejectProposal = (reason, message) => {
219
+ appendEvent({
220
+ eventType: "proposal_creation_rejected",
221
+ ref: input.ref,
222
+ metadata: { source: input.source, reason },
223
+ });
224
+ throw new UsageError(message, "INVALID_PROPOSAL");
225
+ };
226
+ let parsedRef;
227
+ try {
228
+ parsedRef = parseAssetRef(input.ref);
229
+ }
230
+ catch (err) {
231
+ return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ if (!TYPE_DIRS[parsedRef.type]) {
234
+ return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
235
+ }
236
+ if (!input.payload.content.trim()) {
237
+ return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
238
+ }
239
+ // Description check is only enforced for `consolidate` source — that's the
240
+ // automated pipeline that historically produced proposals with missing or
241
+ // malformed frontmatter, polluting the queue with hundreds of unusable
242
+ // entries. Reflect / distill / propose proposals have varied legitimate
243
+ // shapes and should not be rejected here for missing description.
244
+ if (input.source === "consolidate") {
245
+ const desc = input.payload.frontmatter?.description;
246
+ if (typeof desc !== "string" || desc.trim() === "") {
247
+ return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
248
+ }
249
+ }
102
250
  const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
251
+ if (!input.force) {
252
+ const newHash = contentHash(input.payload.content);
253
+ const nowMs = (ctx?.now ?? Date.now)();
254
+ const cooldownMs = cooldownMsForSource(input.source);
255
+ // Scan pending proposals for ref+source matches.
256
+ const pending = listProposals(stashDir, { ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
257
+ if (pending.length > 0) {
258
+ // Check for identical content hash first (silent skip).
259
+ const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
260
+ if (hashMatch) {
261
+ return {
262
+ skipped: true,
263
+ reason: "content_hash_match",
264
+ message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
265
+ existingProposalId: hashMatch.id,
266
+ };
267
+ }
268
+ // Duplicate pending for same ref+source (different content).
269
+ const firstPending = pending[0];
270
+ return {
271
+ skipped: true,
272
+ reason: "duplicate_pending",
273
+ message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
274
+ existingProposalId: firstPending?.id,
275
+ };
276
+ }
277
+ // Check cooldown against recently archived rejected proposals.
278
+ const rejected = listProposals(stashDir, { ref: normalizedRef, status: "rejected", includeArchive: true })
279
+ .filter((p) => p.source === input.source)
280
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
281
+ if (rejected.length > 0 && rejected[0] !== undefined) {
282
+ const mostRecent = rejected[0];
283
+ // Check content hash against recently rejected.
284
+ if (contentHash(mostRecent.payload.content) === newHash) {
285
+ return {
286
+ skipped: true,
287
+ reason: "content_hash_match",
288
+ message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
289
+ existingProposalId: mostRecent.id,
290
+ };
291
+ }
292
+ // Check cooldown window.
293
+ const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
294
+ if (nowMs - rejectedAt < cooldownMs) {
295
+ const cooldownDays = cooldownMs / MS_PER_DAY;
296
+ const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
297
+ return {
298
+ skipped: true,
299
+ reason: "cooldown",
300
+ message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
301
+ `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
302
+ existingProposalId: mostRecent.id,
303
+ };
304
+ }
305
+ }
306
+ }
103
307
  const id = newId(ctx);
104
308
  const created = nowIso(ctx);
309
+ // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
310
+ // is dropped silently — we never store NaN, Infinity, or out-of-range values.
311
+ // Callers that mis-report confidence should not poison the auto-accept gate.
312
+ const sanitizedConfidence = typeof input.confidence === "number" &&
313
+ Number.isFinite(input.confidence) &&
314
+ input.confidence >= 0 &&
315
+ input.confidence <= 1
316
+ ? input.confidence
317
+ : undefined;
105
318
  const proposal = {
106
319
  id,
107
320
  ref: normalizedRef,
@@ -114,6 +327,7 @@ export function createProposal(stashDir, input, ctx) {
114
327
  content: input.payload.content,
115
328
  ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
116
329
  },
330
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
117
331
  };
118
332
  writeProposalFile(proposalFile(stashDir, id, false), proposal);
119
333
  return proposal;
@@ -158,7 +372,7 @@ export function listProposals(stashDir, options = {}) {
158
372
  out.push({
159
373
  id: entry.name,
160
374
  ref: "unknown:unknown",
161
- status: "pending",
375
+ status: "rejected",
162
376
  source: "invalid",
163
377
  createdAt: "",
164
378
  updatedAt: "",
@@ -190,6 +404,53 @@ export function getProposal(stashDir, id) {
190
404
  return readProposalFile(archivedPath);
191
405
  throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
192
406
  }
407
+ /**
408
+ * Resolve a proposal by full UUID, UUID prefix, or asset ref.
409
+ *
410
+ * Resolution order:
411
+ * 1. Exact UUID match (existing behaviour).
412
+ * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
413
+ * that ref; falls back to archived if nothing is pending.
414
+ * 3. UUID prefix — matches any live proposal directory whose name starts
415
+ * with the given string; throws if ambiguous.
416
+ */
417
+ export function resolveProposalId(stashDir, idOrRef) {
418
+ // 1. Exact UUID.
419
+ try {
420
+ return getProposal(stashDir, idOrRef);
421
+ }
422
+ catch (e) {
423
+ if (!(e instanceof NotFoundError))
424
+ throw e;
425
+ }
426
+ // 2. Asset ref (e.g. "skill:akm-dream").
427
+ if (idOrRef.includes(":")) {
428
+ const pending = listProposals(stashDir, { ref: idOrRef });
429
+ if (pending.length > 0) {
430
+ return pending.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
431
+ }
432
+ const archived = listProposals(stashDir, { ref: idOrRef, includeArchive: true });
433
+ if (archived.length > 0) {
434
+ return archived.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
435
+ }
436
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
437
+ }
438
+ // 3. UUID prefix.
439
+ const liveDir = getProposalsRoot(stashDir, false);
440
+ let prefixMatches = [];
441
+ try {
442
+ prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
443
+ }
444
+ catch {
445
+ /* live dir may not exist yet */
446
+ }
447
+ if (prefixMatches.length === 1)
448
+ return getProposal(stashDir, prefixMatches[0]);
449
+ if (prefixMatches.length > 1) {
450
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
451
+ }
452
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
453
+ }
193
454
  /**
194
455
  * Whether a proposal currently lives in the archive (used by callers that
195
456
  * need to know whether to look in the archive root for files / paths).
@@ -241,49 +502,144 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
241
502
  return updated;
242
503
  }
243
504
  /**
244
- * Validate a proposal payload before promotion. Generic by default any
245
- * proposal must parse cleanly and carry a non-empty body. Lessons get the
246
- * extra per-type lint from {@link lintLessonContent} so the contract documented
247
- * in v1 spec §13 is enforced at promotion time. Other asset types can hook
248
- * here in the future without changing call sites.
505
+ * Scan all pending proposals and reject those whose target asset no longer
506
+ * exists on disk across any of `sourceDirs`. Intended to run as a periodic
507
+ * maintenance pass (see `runImproveMaintenancePasses`) it keeps the queue
508
+ * from accumulating stale reviewer work after large refactors or deletes.
509
+ *
510
+ * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
511
+ * Lessons, propose, distill, and consolidate proposals legitimately target
512
+ * assets that don't exist yet and must never be purged.
249
513
  */
250
- export function validateProposal(proposal) {
251
- const findings = [];
252
- if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
253
- findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
254
- }
255
- let ref;
256
- try {
257
- ref = parseAssetRef(proposal.ref);
258
- }
259
- catch (err) {
260
- findings.push({
261
- kind: "invalid-ref",
262
- message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
514
+ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
515
+ const t0 = Date.now();
516
+ const orphans = [];
517
+ const byType = {};
518
+ const pending = listProposals(stashDir, { status: "pending" });
519
+ const reflectPending = pending.filter((p) => p.source === "reflect");
520
+ for (const p of reflectPending) {
521
+ let parsed;
522
+ try {
523
+ parsed = parseAssetRef(p.ref);
524
+ }
525
+ catch {
526
+ continue;
527
+ }
528
+ // Lessons are new-asset proposals by definition — they cannot be orphaned.
529
+ if (parsed.type === "lesson")
530
+ continue;
531
+ const spec = TYPE_DIRS[parsed.type];
532
+ if (!spec)
533
+ continue;
534
+ const exists = sourceDirs.some((root) => {
535
+ const typeRoot = path.join(root, spec);
536
+ const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
537
+ return fs.existsSync(candidate);
263
538
  });
264
- return { ok: false, findings };
539
+ if (!exists) {
540
+ try {
541
+ archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
542
+ orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
543
+ byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
544
+ }
545
+ catch (err) {
546
+ // Best-effort — the purge is non-fatal. Log and continue.
547
+ warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
548
+ }
549
+ }
265
550
  }
266
- // Generic frontmatter parse check for markdown-y types. If the content
267
- // *looks* like it has frontmatter (`---\n…\n---`) we ensure it parses.
268
- if (proposal.payload.content.startsWith("---")) {
551
+ return {
552
+ checked: reflectPending.length,
553
+ rejected: orphans.length,
554
+ durationMs: Date.now() - t0,
555
+ byType,
556
+ orphans,
557
+ };
558
+ }
559
+ /**
560
+ * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
561
+ * D6b / Phase 6B).
562
+ *
563
+ * Reviewer fatigue and queue rot are the dominant failure modes of any
564
+ * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
565
+ * proposals that have aged past the retention window are very rarely accepted
566
+ * — the reviewer either intentionally declined to act on them, or the asset
567
+ * they target has drifted enough that the proposal is no longer relevant.
568
+ * Auto-expiring them keeps the live queue focused on actionable work; the
569
+ * archive preserves the full audit trail.
570
+ *
571
+ * Each expired proposal is archived with status `rejected` and reason
572
+ * `"expired: no action within retention window"`. A `proposal_expired` event
573
+ * is appended for each expired proposal so downstream observability (events
574
+ * dashboards, source-acceptance-rate aggregations) can see expiry separately
575
+ * from explicit rejections.
576
+ *
577
+ * Idempotent: a second call within the same retention window finds nothing
578
+ * to expire (the archived entries are no longer in the pending queue).
579
+ */
580
+ export function expireStaleProposals(stashDir, config, ctx) {
581
+ const t0 = Date.now();
582
+ const retentionDays = config.archiveRetentionDays ?? 90;
583
+ const expiredProposals = [];
584
+ // retentionDays === 0 disables TTL cleanup globally (mirrors how
585
+ // consolidate.ts interprets the same config value).
586
+ if (retentionDays <= 0) {
587
+ return {
588
+ checked: 0,
589
+ expired: 0,
590
+ durationMs: Date.now() - t0,
591
+ retentionDays,
592
+ expiredProposals,
593
+ };
594
+ }
595
+ const retentionMs = retentionDays * MS_PER_DAY;
596
+ const nowMs = (ctx?.now ?? Date.now)();
597
+ const pending = listProposals(stashDir, { status: "pending" });
598
+ for (const p of pending) {
599
+ const createdMs = new Date(p.createdAt).getTime();
600
+ if (!Number.isFinite(createdMs))
601
+ continue;
602
+ const ageMs = nowMs - createdMs;
603
+ if (ageMs < retentionMs)
604
+ continue;
269
605
  try {
270
- parseFrontmatter(proposal.payload.content);
271
- }
272
- catch (err) {
273
- findings.push({
274
- kind: "invalid-frontmatter",
275
- message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
606
+ archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
607
+ const ageDays = Math.floor(ageMs / MS_PER_DAY);
608
+ expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
609
+ appendEvent({
610
+ eventType: "proposal_expired",
611
+ ref: p.ref,
612
+ metadata: {
613
+ proposalId: p.id,
614
+ source: p.source,
615
+ ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
616
+ ageDays,
617
+ retentionDays,
618
+ },
276
619
  });
277
620
  }
278
- }
279
- // Type-specific validators.
280
- if (ref.type === "lesson") {
281
- const lessonReport = lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`);
282
- for (const finding of lessonReport.findings) {
283
- findings.push({ kind: finding.kind, message: finding.message });
621
+ catch (err) {
622
+ // Best-effort — a single failure must not block the pass.
623
+ warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
284
624
  }
285
625
  }
286
- return { ok: findings.length === 0, findings };
626
+ return {
627
+ checked: pending.length,
628
+ expired: expiredProposals.length,
629
+ durationMs: Date.now() - t0,
630
+ retentionDays,
631
+ expiredProposals,
632
+ };
633
+ }
634
+ /**
635
+ * Validate a proposal payload before promotion. Generic by default — any
636
+ * proposal must parse cleanly and carry a non-empty body. Lessons get the
637
+ * extra per-type lint from {@link lintLessonContent} so the contract documented
638
+ * in v1 spec §13 is enforced at promotion time. Other asset types can hook
639
+ * here in the future without changing call sites.
640
+ */
641
+ export function validateProposal(proposal) {
642
+ return runProposalValidators(proposal);
287
643
  }
288
644
  /**
289
645
  * Validate a proposal, then promote it through the canonical
@@ -291,6 +647,12 @@ export function validateProposal(proposal) {
291
647
  * `source.kind`). On success the proposal directory is moved to the archive
292
648
  * with status `accepted`. Validation failures throw a `UsageError` carrying
293
649
  * every finding so the CLI can render a single clear error envelope.
650
+ *
651
+ * Phase 6C: when the target asset already exists at the resolved write path,
652
+ * a snapshot of the prior content is captured under
653
+ * `<proposalsRoot>/<id>/backup.<ext>` BEFORE the write. The relative path is
654
+ * recorded on the proposal record (`backup` field) so `akm proposal revert`
655
+ * can restore the prior content. Genuinely-new assets carry no backup.
294
656
  */
295
657
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
296
658
  const proposal = getProposal(stashDir, id);
@@ -307,10 +669,104 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
307
669
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
308
670
  }
309
671
  const target = resolveWriteTarget(config, options.target);
672
+ // Phase 6C: capture a backup of the prior content (if any) BEFORE writing the
673
+ // new asset. We use the resolved write target to compute the exact path the
674
+ // asset would land at — same resolver `writeAssetToSource` uses — so the
675
+ // backup always mirrors what would be overwritten.
676
+ let backupRelPath;
677
+ try {
678
+ const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
679
+ if (targetFilePath && fs.existsSync(targetFilePath)) {
680
+ const ext = path.extname(targetFilePath) || ".md";
681
+ const proposalRoot = proposalDir(stashDir, id, false);
682
+ // Store relative path on the proposal record so the directory remains
683
+ // portable if the stash is moved.
684
+ const backupFilename = `backup${ext}`;
685
+ const backupAbsPath = path.join(proposalRoot, backupFilename);
686
+ fs.mkdirSync(proposalRoot, { recursive: true });
687
+ // Use copyFileSync — file-system atomicity is sufficient here because the
688
+ // backup is single-file and never read concurrently with this write.
689
+ fs.copyFileSync(targetFilePath, backupAbsPath);
690
+ backupRelPath = backupFilename;
691
+ }
692
+ }
693
+ catch (err) {
694
+ // Backup capture is best-effort. A failure here must not block promotion
695
+ // (the user explicitly asked to accept); we surface a warning so the
696
+ // missing-revert path is visible.
697
+ warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
698
+ }
310
699
  const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
311
700
  const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
701
+ // Persist the backup path on the archived proposal record. archiveProposal
702
+ // moves the proposal dir into the archive subtree, so the backup file moves
703
+ // with it (the relative path stays valid).
704
+ if (backupRelPath) {
705
+ const archivedFile = proposalFile(stashDir, id, true);
706
+ const withBackup = { ...archived, backup: backupRelPath };
707
+ writeProposalFile(archivedFile, withBackup);
708
+ return { proposal: withBackup, assetPath: written.path, ref: written.ref };
709
+ }
312
710
  return { proposal: archived, assetPath: written.path, ref: written.ref };
313
711
  }
712
+ /**
713
+ * Restore the prior content of an accepted proposal from its captured backup
714
+ * (Advantage D6c / Phase 6C).
715
+ *
716
+ * Pre-conditions:
717
+ * - `id` resolves to a proposal with `status === "accepted"`.
718
+ * - The proposal carries a `backup` field pointing to a readable file under
719
+ * the proposal directory.
720
+ *
721
+ * On success:
722
+ * - The backup content is written back through {@link writeAssetToSource},
723
+ * so the canonical write-dispatch invariant is preserved.
724
+ * - The archived proposal record is updated to `status: "reverted"`.
725
+ * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
726
+ * `promoted` / `rejected` are emitted by the CLI command, not the core).
727
+ *
728
+ * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
729
+ * cleanly to exit codes — see `src/commands/proposal.ts` for the wrapper.
730
+ */
731
+ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
732
+ const proposal = getProposal(stashDir, id);
733
+ if (proposal.status !== "accepted") {
734
+ throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
735
+ }
736
+ if (!proposal.backup) {
737
+ 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.");
738
+ }
739
+ // The proposal directory has been moved to the archive subtree (archiveProposal
740
+ // runs at the end of promoteProposal). Reads must resolve against that path.
741
+ const proposalRoot = proposalDir(stashDir, id, true);
742
+ const backupAbsPath = path.join(proposalRoot, proposal.backup);
743
+ if (!fs.existsSync(backupAbsPath)) {
744
+ 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.`);
745
+ }
746
+ const backupContent = fs.readFileSync(backupAbsPath, "utf8");
747
+ const ref = parseAssetRef(proposal.ref);
748
+ if (!TYPE_DIRS[ref.type]) {
749
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
750
+ }
751
+ const target = resolveWriteTarget(config, options.target);
752
+ const written = await writeAssetToSource(target.source, target.config, ref, backupContent);
753
+ // Update the archived proposal record to status: "reverted" and bump
754
+ // updatedAt + review so the audit trail reflects the second decision.
755
+ const archivedFile = proposalFile(stashDir, id, true);
756
+ const now = nowIso(ctx);
757
+ const reverted = {
758
+ ...proposal,
759
+ status: "reverted",
760
+ updatedAt: now,
761
+ review: {
762
+ outcome: "rejected",
763
+ reason: "reverted: prior content restored from backup",
764
+ decidedAt: now,
765
+ },
766
+ };
767
+ writeProposalFile(archivedFile, reverted);
768
+ return { proposal: reverted, assetPath: written.path, ref: written.ref };
769
+ }
314
770
  /**
315
771
  * Compute a diff between a proposal payload and the existing on-disk asset.
316
772
  * Uses {@link resolveWriteTarget} to find where the asset would land — so the