akm-cli 0.9.31 → 0.10.261002-beta

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 (270) hide show
  1. package/CHANGELOG.md +420 -0
  2. package/STABILITY.md +99 -57
  3. package/dist/api-node.mjs +20 -0
  4. package/dist/api.d.ts +23 -0
  5. package/dist/api.js +45 -0
  6. package/dist/assets/hints/cli-hints-full.md +15 -29
  7. package/dist/assets/hints/cli-hints-short.md +2 -2
  8. package/dist/assets/improve-strategies/default.json +3 -3
  9. package/dist/assets/prompts/consolidate-pair.md +9 -7
  10. package/dist/assets/stash-skeleton/README.md +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  12. package/dist/cli/shared.js +31 -48
  13. package/dist/cli/unknown-flags.js +6 -43
  14. package/dist/cli.js +8 -34
  15. package/dist/commands/agent/contribute-cli.js +1 -1
  16. package/dist/commands/completions.js +1 -2
  17. package/dist/commands/config-cli.js +5 -28
  18. package/dist/commands/env/env-cli.js +5 -5
  19. package/dist/commands/env/secret-cli.js +2 -2
  20. package/dist/commands/feedback-cli.js +75 -49
  21. package/dist/commands/health/checks.js +41 -16
  22. package/dist/commands/health/plugin-staleness.js +52 -22
  23. package/dist/commands/improve/autonomy-gate.js +5 -3
  24. package/dist/commands/improve/consolidate/chunking.js +3 -10
  25. package/dist/commands/improve/consolidate/continuity-check.js +4 -4
  26. package/dist/commands/improve/consolidate/pair-pass.js +29 -10
  27. package/dist/commands/improve/consolidate.js +10 -65
  28. package/dist/commands/improve/distill/lesson-update.js +91 -0
  29. package/dist/commands/improve/distill-guards.js +1 -43
  30. package/dist/commands/improve/distill.js +148 -21
  31. package/dist/commands/improve/eligibility.js +14 -1
  32. package/dist/commands/improve/extract-cli.js +1 -1
  33. package/dist/commands/improve/extract-prompt.js +17 -7
  34. package/dist/commands/improve/extract.js +5 -7
  35. package/dist/commands/improve/improve-cli.js +92 -106
  36. package/dist/commands/improve/improve-strategies.js +8 -12
  37. package/dist/commands/improve/improve.js +134 -52
  38. package/dist/commands/improve/loop-stages.js +18 -5
  39. package/dist/commands/improve/planner.js +0 -1
  40. package/dist/commands/improve/preparation.js +40 -99
  41. package/dist/commands/improve/proactive-maintenance.js +2 -2
  42. package/dist/commands/improve/reflect-noise.js +3 -55
  43. package/dist/commands/improve/reflect.js +29 -34
  44. package/dist/commands/improve/retrieval-queries.js +37 -0
  45. package/dist/commands/improve/retrieval-scope.js +2 -2
  46. package/dist/commands/improve/salience.js +2 -9
  47. package/dist/commands/improve/session-asset.js +4 -1
  48. package/dist/commands/improve/stage.js +23 -12
  49. package/dist/commands/lint/index.js +5 -20
  50. package/dist/commands/observability-cli.js +3 -9
  51. package/dist/commands/proposal/drain.js +43 -6
  52. package/dist/commands/proposal/proposal-cli.js +11 -31
  53. package/dist/commands/proposal/repository.js +20 -14
  54. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -3
  55. package/dist/commands/read/knowledge.js +3 -3
  56. package/dist/commands/read/remember-cli.js +0 -13
  57. package/dist/commands/read/search-cli.js +29 -97
  58. package/dist/commands/read/search.js +0 -11
  59. package/dist/commands/read/show.js +0 -19
  60. package/dist/commands/sources/plugin-upgrade.js +164 -101
  61. package/dist/commands/sources/sources-cli.js +1 -15
  62. package/dist/commands/sources/stash-cli.js +6 -14
  63. package/dist/commands/tasks/tasks-cli.js +4 -115
  64. package/dist/commands/workflow/plan.js +11 -33
  65. package/dist/commands/workflow-cli.js +2 -7
  66. package/dist/core/adapter/adapter-ids.js +1 -2
  67. package/dist/core/adapter/adapters/agent-skills-adapter.js +0 -4
  68. package/dist/core/adapter/adapters/akm-adapter.js +9 -48
  69. package/dist/core/adapter/adapters/akm-lint.js +10 -26
  70. package/dist/core/adapter/adapters/akm-metadata.js +2 -72
  71. package/dist/core/adapter/adapters/akm-task-adapter.js +0 -5
  72. package/dist/core/adapter/adapters/akm-workflow-adapter.js +9 -28
  73. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -20
  74. package/dist/core/adapter/adapters/generic-files-adapter.js +0 -10
  75. package/dist/core/adapter/adapters/llm-wiki-adapter.js +0 -4
  76. package/dist/core/adapter/adapters/tool-dir-shared.js +0 -24
  77. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -4
  78. package/dist/core/adapter/recognize-match.js +3 -4
  79. package/dist/core/common.js +18 -2
  80. package/dist/core/config/config-schema.js +12 -15
  81. package/dist/core/config/config-types.js +6 -15
  82. package/dist/core/config/config-walker.js +6 -23
  83. package/dist/core/config/config.js +17 -80
  84. package/dist/core/config/engine-semantics.js +7 -3
  85. package/dist/core/config/schema/engines.js +7 -7
  86. package/dist/core/config/schema/improve-processes.js +10 -63
  87. package/dist/core/config/schema/improve.js +2 -21
  88. package/dist/core/config/schema/index-config.js +6 -39
  89. package/dist/core/env-secret-ref.js +2 -45
  90. package/dist/core/errors.js +1 -2
  91. package/dist/core/extra-params.js +2 -86
  92. package/dist/core/file-lock.js +2 -6
  93. package/dist/core/improve-result.js +13 -1
  94. package/dist/core/mutation-target.js +2 -2
  95. package/dist/core/paths.js +4 -0
  96. package/dist/core/recognition-util.js +4 -5
  97. package/dist/core/run-lock.js +1 -1
  98. package/dist/core/subprocess.js +49 -0
  99. package/dist/core/warn.js +21 -4
  100. package/dist/core/write-source.js +3 -3
  101. package/dist/execution/target-ref.js +1 -2
  102. package/dist/indexer/indexer.js +15 -6
  103. package/dist/indexer/passes/memory-inference.js +15 -6
  104. package/dist/indexer/search/db-search.js +49 -36
  105. package/dist/indexer/search/fts-query.js +0 -19
  106. package/dist/indexer/search/ranking.js +29 -1
  107. package/dist/indexer/usage/usage-events.js +21 -16
  108. package/dist/integrations/agent/engine-resolution.js +5 -2
  109. package/dist/integrations/agent/index.js +1 -1
  110. package/dist/integrations/agent/profiles.js +41 -98
  111. package/dist/integrations/agent/request-lowering.js +3 -3
  112. package/dist/integrations/harnesses/aider/index.js +5 -0
  113. package/dist/integrations/harnesses/amazonq/index.js +1 -0
  114. package/dist/integrations/harnesses/claude/config-import.js +2 -3
  115. package/dist/integrations/harnesses/claude/index.js +5 -0
  116. package/dist/integrations/harnesses/codex/index.js +1 -0
  117. package/dist/integrations/harnesses/copilot/index.js +1 -0
  118. package/dist/integrations/harnesses/gemini/index.js +1 -0
  119. package/dist/integrations/harnesses/index.js +18 -39
  120. package/dist/integrations/harnesses/opencode/agent-builder.js +60 -35
  121. package/dist/integrations/harnesses/opencode/config-import.js +121 -52
  122. package/dist/integrations/harnesses/opencode/index.js +22 -0
  123. package/dist/integrations/harnesses/opencode/plugin-layout.js +68 -0
  124. package/dist/integrations/harnesses/opencode/session-log-types.js +12 -0
  125. package/dist/integrations/harnesses/opencode/session-log-v1.js +72 -0
  126. package/dist/integrations/harnesses/opencode/session-log-v2.js +140 -0
  127. package/dist/integrations/harnesses/opencode/session-log.js +104 -115
  128. package/dist/integrations/harnesses/opencode/version.js +98 -0
  129. package/dist/integrations/harnesses/opencode-sdk/{harness.js → index.js} +5 -17
  130. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +105 -206
  131. package/dist/integrations/harnesses/opencode-sdk/v1-adapter.js +199 -0
  132. package/dist/integrations/harnesses/opencode-sdk/v2-adapter.js +254 -0
  133. package/dist/integrations/harnesses/opencode-sdk/wire.js +66 -0
  134. package/dist/integrations/harnesses/openhands/index.js +1 -0
  135. package/dist/integrations/harnesses/pi/index.js +1 -0
  136. package/dist/integrations/harnesses/shared.js +2 -0
  137. package/dist/integrations/harnesses/types.js +2 -0
  138. package/dist/llm/client.js +41 -11
  139. package/dist/output/cli-hints.js +1 -1
  140. package/dist/output/context.js +10 -19
  141. package/dist/output/format-exempt.js +5 -8
  142. package/dist/output/shapes/helpers.js +1 -22
  143. package/dist/output/shapes/passthrough.js +0 -1
  144. package/dist/output/shapes.js +3 -31
  145. package/dist/output/stdout.js +5 -1
  146. package/dist/output/text/command-format.js +6 -1
  147. package/dist/output/text/workflow-format.js +4 -22
  148. package/dist/scripts/akm-migrate-node.js +15745 -16711
  149. package/dist/scripts/akm-migrate.js +16318 -17280
  150. package/dist/setup/detect.js +10 -4
  151. package/dist/setup/harness-config-import.js +5 -14
  152. package/dist/setup/setup.js +1 -1
  153. package/dist/setup/steps/tasks.js +12 -13
  154. package/dist/sources/providers/git-stash.js +113 -16
  155. package/dist/storage/repositories/index-utility-repository.js +2 -32
  156. package/dist/storage/repositories/index-vec-repository.js +4 -0
  157. package/dist/storage/repositories/task-history-repository.js +0 -5
  158. package/dist/storage/repositories/workflow-runs-repository.js +5 -15
  159. package/dist/tasks/embedded.js +2 -1
  160. package/dist/tasks/run/run-command-task.js +9 -1
  161. package/dist/tasks/run/run-workflow-task.js +4 -3
  162. package/dist/tasks/run/task-history.js +0 -2
  163. package/dist/tasks/run/task-log.js +38 -1
  164. package/dist/tasks/schedule-gates.js +39 -0
  165. package/dist/tasks/scheduler-binding.js +0 -16
  166. package/dist/tasks/scheduler-sync.js +3 -97
  167. package/dist/tasks/source/bounded-document.js +1 -1
  168. package/dist/tasks/source/task-source-v4.js +2 -12
  169. package/dist/tasks/task-run-reserved-flags.js +2 -31
  170. package/dist/workflows/authoring/authoring.js +6 -6
  171. package/dist/workflows/compile.js +8 -65
  172. package/dist/workflows/exec/child-workflow.js +22 -3
  173. package/dist/workflows/exec/native-executor.js +12 -147
  174. package/dist/workflows/exec/run-workflow.js +31 -103
  175. package/dist/workflows/exec/step-work.js +9 -178
  176. package/dist/workflows/exec/unit-dispatch.js +0 -10
  177. package/dist/workflows/freeze/environment.js +3 -44
  178. package/dist/workflows/freeze/freeze.js +8 -41
  179. package/dist/workflows/freeze/targets/child-workflow.js +27 -63
  180. package/dist/workflows/freeze/targets/command.js +2 -16
  181. package/dist/workflows/freeze/targets/shell.js +0 -2
  182. package/dist/workflows/freeze/task-bindings.js +2 -28
  183. package/dist/workflows/ir/params.js +12 -2
  184. package/dist/workflows/parser.js +82 -232
  185. package/dist/workflows/plan.js +1 -1
  186. package/dist/workflows/renderer.js +10 -19
  187. package/dist/workflows/runtime/run-plan.js +11 -53
  188. package/dist/workflows/runtime/runs.js +4 -27
  189. package/dist/workflows/source-files.js +12 -16
  190. package/dist/workflows/source-semantics.js +1 -93
  191. package/docs/README.md +1 -1
  192. package/docs/agents/README.md +3 -3
  193. package/docs/agents/agent-install.md +1 -1
  194. package/docs/architecture/0.10-process-rules.md +827 -0
  195. package/docs/architecture/adapters.md +17 -31
  196. package/docs/architecture/architecture.md +4 -5
  197. package/docs/architecture/improvement.md +115 -76
  198. package/docs/architecture/internals/improve-workflow.md +22 -16
  199. package/docs/architecture/internals/indexing.md +1 -1
  200. package/docs/architecture/internals/search.md +1 -2
  201. package/docs/architecture/internals/storage-locations.md +6 -6
  202. package/docs/architecture/persisted-data-compat.md +13 -4
  203. package/docs/architecture/specs/0.9.0-decisions.md +6 -2
  204. package/docs/architecture/specs/improve-collapse-churn-detector-design.md +2 -2
  205. package/docs/architecture/specs/workflow-format-unification.md +1 -6
  206. package/docs/architecture/testing/manual-testing-checklist.md +21 -30
  207. package/docs/architecture/testing/task-workflow-manual-tests.md +106 -203
  208. package/docs/architecture/testing/testing-workflow.md +1 -1
  209. package/docs/architecture/workflow-engine.md +5 -56
  210. package/docs/guides/author-workflows.md +39 -69
  211. package/docs/guides/bundles.md +3 -1
  212. package/docs/guides/capture-knowledge.md +1 -1
  213. package/docs/guides/claude-code-vs-akm-workflows.md +1 -2
  214. package/docs/guides/discover-and-load.md +4 -4
  215. package/docs/guides/getting-started.md +1 -1
  216. package/docs/guides/improve-the-library.md +10 -9
  217. package/docs/guides/recipes/headless-install.md +2 -2
  218. package/docs/guides/run-workflows.md +1 -1
  219. package/docs/guides/scheduling.md +9 -1
  220. package/docs/guides/use-with-any-agent.md +49 -4
  221. package/docs/integration/bundling-akm.md +7 -6
  222. package/docs/maintainers/curate-workmap.md +1 -2
  223. package/docs/maintainers/release-checklist.md +33 -1
  224. package/docs/migration/README.md +2 -0
  225. package/docs/migration/release-notes/0.10.0.md +201 -0
  226. package/docs/migration/release-notes/README.md +1 -0
  227. package/docs/migration/v0.9-to-v0.10.md +507 -0
  228. package/docs/posts/akm-command-tour.md +3 -3
  229. package/docs/product-surface.md +1 -1
  230. package/docs/reference/README.md +1 -0
  231. package/docs/reference/api.md +65 -0
  232. package/docs/reference/asset-types.md +3 -5
  233. package/docs/reference/bundle-types.md +2 -4
  234. package/docs/reference/cli.md +160 -188
  235. package/docs/reference/configuration.md +101 -20
  236. package/docs/reference/data-and-telemetry.md +2 -2
  237. package/docs/reference/env-and-secrets.md +2 -2
  238. package/docs/reference/supported-formats.md +3 -4
  239. package/docs/reference/tasks.md +12 -19
  240. package/docs/reference/website-sources.md +1 -1
  241. package/docs/reference/workflow-schema.md +89 -317
  242. package/docs/reference/workflows.md +20 -48
  243. package/package.json +12 -2
  244. package/schemas/akm-config.json +2 -243
  245. package/schemas/akm-task.json +2 -3
  246. package/schemas/akm-workflow.json +47 -84
  247. package/dist/assets/improve-strategies/catchup.json +0 -41
  248. package/dist/assets/improve-strategies/proactive-maintenance.json +0 -24
  249. package/dist/assets/improve-strategies/quick.json +0 -17
  250. package/dist/assets/improve-strategies/reflect-distill.json +0 -29
  251. package/dist/assets/improve-strategies/thorough.json +0 -49
  252. package/dist/assets/prompts/retrieval-relevance-judge.md +0 -6
  253. package/dist/assets/tasks/improve/akm-improve-catchup.yml +0 -6
  254. package/dist/assets/tasks/improve/akm-improve-frequent.yml +0 -4
  255. package/dist/assets/tasks/improve/akm-improve-nightly.yml +0 -4
  256. package/dist/cli/retired-commands.js +0 -118
  257. package/dist/commands/improve/retrieval-gate.js +0 -132
  258. package/dist/commands/tasks/validate.js +0 -176
  259. package/dist/core/config/legacy-source-shape-shim.js +0 -117
  260. package/dist/indexer/feedback/utility-policy.js +0 -75
  261. package/dist/integrations/harnesses/ids.js +0 -28
  262. package/dist/tasks/source/task-source-v3-frozen.js +0 -424
  263. package/dist/tasks/source/task-to-v4.js +0 -863
  264. package/dist/workflows/exec/worktree.js +0 -372
  265. package/dist/workflows/freeze/child-output-references.js +0 -83
  266. package/dist/workflows/freeze/identity.js +0 -22
  267. package/dist/workflows/freeze/targets/script.js +0 -50
  268. package/dist/workflows/freeze/targets/task.js +0 -124
  269. package/dist/workflows/github-yaml.js +0 -506
  270. package/dist/workflows/runtime/run-outputs.js +0 -80
@@ -0,0 +1,827 @@
1
+ # akm 0.10 process rules
2
+
3
+ *akm release/0.10.0 · read from this branch (708e1e585 plus the 14 fixes and the policy batch in this PR) · published as 0.10.26101002-alpha · read 2026-10-11 · corrected after review #1168 on 2026-10-11*
4
+
5
+ Every rule below was read from the 0.10 source on this branch (708e1e585 plus the fixes and the policy batch in this PR), not from docs or memory. Each one names what it does, why it exists, and the file and line that implements it (paths are relative to the akm repository). A reason comes from a code comment, the CHANGELOG, the docs or a linked issue; where none exists the entry says *Why: not stated in code/docs*. 456 rules across 13 processes.
6
+
7
+ This version applies the corrections from the code review of the first version (PR #1168): inverted rules, wrong citations, and the findings that change what a rule says. Rules that describe a bug fixed on this branch say "(fixed in this PR, #N)", with N the number in the Fixed in this PR list. Figures that come only from a GitHub issue (#986's 24,051 auto-accepted proposals, #1167's 129 pending proposals, #900's 19-21 minutes of CPU, #1092's 21 retirements) appear nowhere in the repository and are quoted as issue claims.
8
+
9
+ This version adds the policy batch: Policy decisions 1, 3, 4, 5, 7 and 9 are implemented in this PR (decision 9 removes reflect's retrieval gate), decisions 2, 6 and 8 are resolved with no code change. A rule whose behaviour changed says "(changed in this PR, decision N)", and every citation to the files those commits touched was moved to the line numbers at this branch's HEAD.
10
+
11
+ ## TLDR
12
+
13
+ - **The improve run** plans from fresh feedback plus a capped proactive lane, works only on the write-target bundle, and only touches assets someone retrieved in the last 90 days or new material. It takes a PID lock, syncs a git-backed bundle like `akm sync` (commit pending managed-path edits, fetch, merge, push) before it reads anything, has a 2-hour budget, commits and pushes only the files it wrote, and records every run in `improve_runs`.
14
+ - **Reflect** runs only on fresh negative feedback (or the proactive lane) and may change only `description`, `when_to_use` and a title, never the body text (a title is added as a `# heading` when the body has none). The judge must score every criterion 4 or higher, and nothing is checked after it. A non-pass with a mean of 3.5 or more and a judge outage go to a person as a pending proposal; a lower non-pass or a defect is refused with no proposal and a hard 14-day window.
15
+ - **Distill** needs feedback in the last 30 days, and skips a memory whose only feedback is a positive with no reason or note (10 of 11 lessons from such memories were rejected). Lessons that pass the judge still go to a person, never the drain. The proactive lane never feeds distill.
16
+ - **Consolidate** only promotes memories to knowledge; the pair pass retires a duplicate only when the claim lists show nothing would be lost. A retirement is staged for the drain only if the claims are clean, there is no continuity risk and the second look agrees. There is no confidence cutoff. A `captureMode: hot` memory is retired, promoted and archived like any other. The pair pass, the coverage gate and the continuity check all need embeddings, which are off by default.
17
+ - **Triage** accepts only what a deterministic gate passed (a proposal whose judge stamp still matches its content hash, or a retirement the pair pass staged), up to 200 per run. The judgment step is off by default and may only accept consolidate promotions. Proactive-lane proposals, distill lessons and uncertain judge verdicts wait for a person.
18
+ - **Feedback**: a negative needs a reason; a positive never triggers a rewrite. Both scale `akm search` and `akm curate` ranking: each hit's fused score is multiplied by 1 + 0.03 × (ln(1+pos) − ln(1+neg)) over its user-attributed feedback, and the utility score they also move is read only by improve and metrics (changed in this PR, decision 1). A negative with an exact fix (`--replace`, `--superseded-by`, `--outdated`) is applied at once, not queued (changed in this PR, decision 3). Retirements are archived, not deleted, and can be reverted byte for byte (on a git-backed bundle the archived copy is purged after 30 days, and revert then rewrites the primary from the proposal's stored bytes).
19
+
20
+ ## Policy decisions
21
+
22
+ *The owner's answers, 2026-10-11. Decisions 1, 3, 4, 5, 7 and 9 are implemented in this PR, and 2, 6 and 8 are resolved with no code change. Each rule a decision changed says "(changed in this PR, decision N)".*
23
+
24
+ 1. **Feedback and ranking** — *implemented in this PR.* `akm search` and `akm curate` multiply each hit's fused RRF score by 1 + 0.03 × (ln(1+pos) − ln(1+neg)), where pos and neg count the asset's user-attributed (`source='user'`) feedback events, so 5 positives lift a hit more than 2 and each further vote counts for less. Improve-attributed feedback does not move ranking. `utility_scores` is untouched and still read only by improve eligibility, salience and metrics; search does not read it. (`src/indexer/search/ranking.ts:62-93`, `src/indexer/search/db-search.ts:336-338`, `src/indexer/usage/usage-events.ts:122-148`)
25
+ 2. **Proactive lane** — *decided: keep it on at cap 15; its proposals wait for a person ([itlackey/akm#1172](https://github.com/itlackey/akm/issues/1172)).* Re-measured on 0.10 without the drain (akm-eval nightly, public corpus, 6 silent defects × 3 runs, two lab models): the lane left a correct fix pending for a person on 12 of 18 silent defects (35B) and 18 of 18 (27B), against 0 with the lane off; a cap of 50 found nothing extra, nothing was applied unattended, and the cost was about 25% more model calls. The earlier "0 → 6-7 defects fixed" figure was taken with the drain accepting the picks, which #1147 removed, and is not comparable. No code change.
26
+ 3. **Exact fixes typed by a person** (`--replace/--with/--source`, `--superseded-by`, `--outdated`) — *implemented in this PR.* `akm feedback` mints the proposal, stamps gate `auto-accepted` / `manual-fix` and accepts it in the same command through `akmProposalAccept`: a normal accept with a normal commit, and `akm proposal revert` works. A refused accept rejects the proposal and fails the command. (`src/commands/feedback-cli.ts:793-824`)
27
+ 4. **Uncertain verdicts go to a person** — *implemented in this PR.* A reflect non-pass with a mean of 3.5 or more (`REFLECT_REVIEW_MIN_SCORE`) becomes a pending `deferred` proposal, reason `judge-uncertain`, with no hard window; below 3.5 it is refused as before. A distill judge with no engine records `review_needed` instead of a 30-day `quality_rejected`. Triage never auto-accepts any of these. The band's data (shipped judge, lab 27B Q2): at 3.5 or more, 17 good and 9 bad among 113 frontmatter-only proposals; below 3.5, 396 labelled body rewrites were 6 good and 285 bad. (`src/commands/improve/reflect.ts:1004-1018,1091-1105,1118-1123`, `src/commands/improve/stage.ts:607-613`)
28
+ 5. **Hot memories** — *implemented in this PR.* There is no hot-specific retire guard, chunk skip or prompt annotation, and `hasHotCaptureMode` and `isHotCapturedMemory` are gone: a `captureMode: hot` memory is retired, promoted and archived like any memory. (`src/commands/improve/consolidate/pair-pass.ts:765-773`, `src/commands/improve/consolidate.ts:642-646,798`, `src/commands/improve/consolidate/chunking.ts:31-35,57`)
29
+ 6. **Distill pass bar and grounding** — *decided: no grounding in the pass bar; the outcome is unchanged because pass and review both go to a person ([itlackey/akm#1173](https://github.com/itlackey/akm/issues/1173)).* Replaying judged lessons with grounding added to the bar (akm-eval distill/pass-bar, two lab models) only moves lessons from pass to review (6 good lessons on the 35B, 2 on the 27B). A passing lesson is minted for a person (`distill-review`) and so is one sent to review, and only the reject line (grounding 2 or less) drops a lesson, which is the same under both bars. No code change.
30
+ 7. **Sync before every improve run** — *implemented in this PR.* A live run calls the same `saveGitStash` sync as `akm sync` right after taking its lock: it commits pending managed-path edits, fetches, fast-forwards or merges, and pushes per the run's push setting. The outcome is `result.preRunSync`, and `stash_synced` events carry a `phase`. A conflict or fetch failure warns and the run continues; there is no agent hand-off (the owner wants one only if it proves needed). `akm sync` itself fetches and merges (fixed in this PR, #5). (`src/commands/improve/improve.ts:286-288,364,977-1004`)
31
+ 8. **Nightly memory-inference warning** — *resolved: by design.* The warning `[improve] memoryInference skipped … Set experimental.improveAutonomy: true` fires only on installs without the opt-in, because a gated lane must never be a silent no-op (`src/commands/improve/improve.ts:1073-1074`, `src/commands/improve/autonomy-gate.ts:26-30,113-116`). On the owner's install `improveAutonomy` is true: memory inference ran every night 10-09 to 10-11 (9, 21 and 4 facts) and state.db holds 0 `autonomy_gated` events since 09-12. No code change.
32
+ 9. **Re-measure** the defect filter, the retrieval gate, staged-retirement precision and the proactive-lane benefit with akm-eval, adding tooling there if it is missing — *done on 0.10 (akm `release/0.10.0` at 3f1156232, two lab models, `--repeat 3`); the retrieval gate is removed in this PR ([itlackey/akm#1174](https://github.com/itlackey/akm/issues/1174)).* The reflect defect filter is kept: it refused 0 of 420 frontmatter-only edits the two models wrote, and on 106 labelled real edits 5 of 78 bad and 0 of 28 good. The retrieval gate is removed ([itlackey/akm#1171](https://github.com/itlackey/akm/issues/1171)): it refused 12-17% of the revisions the judge passed and lowered defects fixed from 84 to 77 (35B) and from 92 to 77 (27B) of 96. Staged-retirement precision is kept as is: 298 of 298 staged retirements were safe, but the consolidate eval uses the deterministic embedder and records no queries, so the continuity check never fires there and is untested by the eval. The proactive lane is kept (decision 2).
33
+
34
+ ## Needs a decision
35
+
36
+ - **Positive feedback without a reason starves distill.** Distill skips it by design, but the shipped hint files tell agents to run `akm feedback <ref> --positive` with no reason (`src/assets/hints/cli-hints-short.md:11,42`, `cli-hints-full.md:96-104`, `src/assets/stash-skeleton/README.md:81`, `docs/reference/cli.md:1792-1795`). The `AGENTS.md` text in your Codex and OpenPalm worktrees says the same. `docs/reference/cli.md:1793` also shows `akm feedback agents/reviewer --negative` with no `--reason`, which is a usage error by default. The bare-positive guard also blocks the promotion path, which needs no writer.
37
+ - **Stale wording in code:** a health check still mentions a staging "confidence threshold" that no longer exists (`src/commands/health/checks.ts:1533`); the salience and valence header comments describe behaviour the code no longer has; `consolidate.json:2` says "merges, deduplicates, and prunes" (the promote pass is promote-only) and "every 4h" although a strategy encodes no schedule; the `--promote` help says "Default is queue mode" (`proposal-cli.ts:436`); `dir-staleness.ts:15,58` describe a fingerprint the code no longer computes; `memory-inference.ts:6,22-30` and `memory-infer.ts:6` describe an `akm index` path; `extract-cli.ts:50` says "Default 24h" (the floor is 48h); `improve-processes.ts:188-189` says `minContentChars` is a pre-filter count (it measures raw chars); `improve-strategies.ts:351-357` describes `allowAllDisabled`; `consolidate.ts:919-920` still says a judged memory "waits out its revisit window". Each section's "Code and doc mismatches" list has the details.
38
+
39
+ *Correction to an earlier report: the 38 pair proposals with no gate decision on 10-11 were not held by a confidence cutoff. Nothing gates on confidence; they failed one of the three staging conditions above.*
40
+
41
+ ## Fixed in this PR
42
+
43
+ These 14 bugs are fixed on this branch; the rules below describe the fixed behaviour and cite it as "(fixed in this PR, #N)".
44
+
45
+ 1. **Reflect failures keep the feedback** — a transient reflect failure (transport, parse, timeout, budget abort, crash) writes no ledger row, so the negative feedback that planned the ref still counts next run.
46
+ 2. **Pair pass without vectors** — an asset with no stored vector is left unsettled (no ledger row) and the pass warns how many were skipped and what to set.
47
+ 3. **Extract lock** — the per-session lock is reclaimed only from a dead holder; the age-based reclaim (`staleAfterMs`, `age_exceeded`) is gone.
48
+ 4. **Command task timeout** — `uses: commands/…` tasks get the 6-hour default timeout when nothing else sets one.
49
+ 5. **`akm sync` fetches** — it fetches and fast-forwards or merges the upstream before pushing; a conflicting merge is aborted, the local commit kept and the push skipped with a reason. Improve reports `committed:true` when only the push failed.
50
+ 6. **Nightly lock collision** — the consolidate task is bounded to 1 hour (`--timeout-ms 3600000`), so the 00:20 run ends before the 02:00 nightly.
51
+ 7. **Budget hard-kill** — before the forced exit 0, a hook records a terminated `improve_runs` row (`budget_exhausted`) and makes a local, unpushed commit of the files the run wrote.
52
+ 8. **Memory inference bundle** — it writes to the `isDefault` bundle, and warns when there is none or it is not an akm bundle.
53
+ 9. **Parent stamp** — the `inferenceProcessed` stamp re-reads the current frontmatter instead of the copy parsed before the LLM call.
54
+ 10. **Retire proposals** — they expire by age like other proposals; a staged one whose target or successor is gone or changed since judging is rejected once as stale-target, and expired or stale-target rejections no longer settle the pair.
55
+ 11. **Revert after the purge** — revert rewrites the primary from `backupContent` when the archived copy has been purged.
56
+ 12. **Session summary prompt** — the transcript is pre-filtered and fenced as untrusted (the summary is still written directly to the stash).
57
+ 13. **Search warning** — `semanticSearchMode: auto` warns only for `embedding.endpoint` set without `embedding.model`.
58
+ 14. **Per-run log files** — the improve and index logs under `$CACHE/logs/` are purged under `improve.eventRetentionDays` (event `run_log_files_purged`).
59
+
60
+ ## Contents
61
+
62
+ - [Policy decisions](#policy-decisions)
63
+ - [Fixed in this PR](#fixed-in-this-pr)
64
+ - [The improve run](#the-improve-run)
65
+ - [Reflect and quality gates](#reflect-and-quality-gates)
66
+ - [Distill](#distill)
67
+ - [Extract](#extract)
68
+ - [Memory inference](#memory-inference)
69
+ - [Memory cleanup](#memory-cleanup)
70
+ - [Consolidate](#consolidate)
71
+ - [Triage and drain](#triage-and-drain)
72
+ - [Proposals and feedback](#proposals-and-feedback)
73
+ - [Index](#index)
74
+ - [Search and curate](#search-and-curate)
75
+ - [Config and compatibility](#config-and-compatibility)
76
+ - [Tasks, workflows and sync](#tasks-workflows-and-sync)
77
+
78
+ ## The improve run
79
+
80
+ ### Strategies & defaults
81
+
82
+ - **Two built-in strategies only: `default` and `consolidate`** — The built-in names are `default` and `consolidate`. Module load throws if a name has no asset. *Why:* the other built-ins were removed in 0.10, see the removed-strategies rule. (`src/core/config/engine-semantics.ts:7`, `src/commands/improve/improve-strategies.ts:102-109`, #1130)
83
+ - **Strategy selection order** — Strategy comes from `--strategy`, else `defaults.improveStrategy`, else `"default"`. *Why:* not stated in code. `docs/architecture/improvement.md` ("Strategy inheritance") only restates the order. (`improve-strategies.ts:111-112`)
84
+ - **Strategies are patches onto `default`** — A built-in is deep-merged onto `default`. A user block of the same name is then deep-merged on top. The result is validated with `ImproveProfileConfigSchema`. A field or process block that is omitted inherits from `default`; an explicit `enabled` in the more specific layer wins. *Why:* #1130 corrected the docs that called built-ins "complete presets". (`improve-strategies.ts:158-164`, CHANGELOG 0.10.26101001 "Built-in improve strategies are patches onto default")
85
+ - **User-defined strategies inherit `default`** — Any `improve.strategies.<name>` block (name must match the engine-name pattern, so it cannot start with `akm-`) is resolved as `default` plus that block. *Why:* not stated beyond the inheritance design above. (`improve-strategies.ts:151-164`, `src/core/config/schema/improve.ts:37`, `src/core/config/engine-semantics.ts:5`)
86
+ - **Unknown strategy name is a hard error** — Naming a strategy that is neither built-in nor user-defined throws `UNKNOWN_IMPROVE_STRATEGY` and lists the valid names. (`improve-strategies.ts:151-157`)
87
+ - **Removed built-in names refuse unless the user defines the block** — `quick`, `thorough`, `catchup`, `reflect-distill` and `proactive-maintenance` fail at resolution time, not at config load, with "removed in 0.10. Use default (or consolidate), or define it under improve.strategies". If the user has a block of that name it works as an ordinary user strategy inheriting `default`. *Why:* measured on the nightly eval, `quick` solved 0.76/0.75 versus `default` 0.92/0.89. `thorough` (the judged drain) solved 0.67/0.70 with 9 and 6.3 harmed items. `catchup` was identical to `consolidate`. `proactive-maintenance` moved into `default` (#1129). (`engine-semantics.ts:9-20`, `improve-strategies.ts:140-150`, #1130, #1129)
88
+ - **`graph-refresh` always refuses** — It is rejected even if a leftover `improve.strategies["graph-refresh"]` override exists. *Why:* the override is only a partial patch, so merging it onto `default` would silently run a full unplanned pass. The strategy was retired in 0.9.17-alpha.9 with the LLM entity-graph extraction. `akm migrate apply` drops the leftover block. (`improve-strategies.ts:114-139`)
89
+ - **`default` strategy values** — These are the shipped values.
90
+ - reflect: enabled, `limit` 25, `allowedTypes` agent, command, knowledge, lesson, memory, skill, workflow.
91
+ - distill: enabled, memory only, `requirePlannedRefs` false.
92
+ - consolidate: enabled, memory only.
93
+ - memoryInference: enabled, but gated.
94
+ - extract: disabled (its triage sub-block has `minScore` 2).
95
+ - validation: enabled.
96
+ - proactiveMaintenance: enabled, `dueDays` 30, `maxPerRun` 15.
97
+ - triage: enabled, `applyMode` "promote", `maxAcceptsPerRun` 200, no judgment.
98
+ - sync: enabled, push true.
99
+ *Why:* Each value has its own rule below. (`src/assets/improve-strategies/default.json:1-18`)
100
+ - **`consolidate` strategy values** — Only consolidate runs (memory only, `maxChunkSize` 25). reflect, distill, memoryInference, extract, triage, validation and proactiveMaintenance are off. Sync is on with push. The description says "every 4h", but nothing enforces a cadence. *Why:* #1130 folded `catchup` into it. (`src/assets/improve-strategies/consolidate.json:1-14`, #1130)
101
+ - **Default allowed types** — If a strategy sets no `allowedTypes`, reflect covers agent, command, knowledge, lesson, memory, skill and workflow. distill and consolidate are memory only. *Why:* not stated. (`improve-strategies.ts:36-40`)
102
+ - **Per-ref skip rule** — A ref is skipped for a process if that process is disabled (`process-disabled`) or its type is not allowed (`type-filter`). For reflect only, it is also skipped if its conceptId equals or starts with an `excludeRefPrefixes` entry (`exclude-filter`). *Why:* raw wiki-ingest snapshots are type `knowledge`, so `allowedTypes` alone cannot exclude them (code tag R12). distill and consolidate are memory only and reject `excludeRefPrefixes` in the schema. (`improve-strategies.ts:56-83`, `src/core/config/schema/improve-processes.ts:55-61,228-237,258-267`)
103
+ - **Strategy-filtered-for-all-passes** — A ref that both reflect and distill would skip is dropped at selection. It is reported once as an aggregate `strategy_filtered_all_passes` event, never one event per ref (#592). It also appears in the plan's `profile` gate and `strategyFilteredRefs`. (`improve-strategies.ts:98-100`, `src/commands/improve/eligibility.ts:285-293`, `src/commands/improve/improve.ts:1064-1071`)
104
+ - **Unknown enabled process names are rejected** — An unknown process key with `enabled: true` fails validation. Unknown disabled keys are preserved for cross-version compatibility. `graphExtraction` and `feedbackDistillation` are tolerated retired names. *Why:* this version cannot execute an unknown process. (`improve-processes.ts:292-334`)
105
+ - **Plan is resolved before any side effect** — `resolveImprovePlan` resolves a runner for every enabled model-calling process, then freezes the plan. The model-calling processes are every engine process except triage (reflect, distill, consolidate, memoryInference, extract, validation). Triage uses its judgment engine. proactiveMaintenance uses no engine. (`improve-strategies.ts:173-175,336-369,395-552`)
106
+ - **Engine or credential unavailable disables just that process** — A process with no engine, or whose credential is unreachable in this environment, is set `enabled:false` and listed in `engineUnavailable`. The run warns, emits an `improve_skipped` event with `engine_unavailable`, and reports `skippedProcesses`. *Why:* #957 (a run that reported success while every LLM process was skipped). (`improve-strategies.ts:457-474`, `improve.ts:1082-1090`, #957)
107
+ - **Total no-op strategy aborts** — If any process is unavailable and no engine process remains enabled, plan resolution throws `LLM_NOT_CONFIGURED`. proactiveMaintenance does not count as keeping a run alive, because it is only a selector. A dry run tolerates the credential-only case (`allowAllDisabled`). *Why:* #957. (`improve-strategies.ts:484-495`, `improve-cli.ts:423`)
108
+ - **Triage judgment engine required when judgment is on** — Triage enabled with `judgment.enabled` but no resolvable engine throws `LLM_NOT_CONFIGURED`. An engine with an unavailable credential is folded into `engineUnavailable` as `triage.judgment`. (`improve-strategies.ts:497-532`)
109
+ - **Validation repair needs no engine when repair is off** — Validation's structural check always runs. Only its optional LLM repair needs an engine, so `repairValidationFailures:false` creates no preflight. (`improve-strategies.ts:421-423`)
110
+ - **Limit precedence** — The effective cap is `--limit`, then `processes.reflect.limit`, then the strategy `limit`. The plan reports all three as configured limits. `additiveReplayAllowance` is always 0 because the replay lane is gone. *Why:* not stated. (`improve.ts:477-485`, `src/commands/improve/planner.ts:134-140`)
111
+
112
+ ### Scope & eligibility
113
+
114
+ - **Scope positional parsing** — No scope means `all`. A parseable ref means `ref` mode. A bare word with none of `/ : #` means `type` mode, and an unknown type just matches nothing. A ref-shaped value that fails to parse is a `UsageError INVALID_FLAG_VALUE`. *Why:* not stated. (`src/commands/improve/eligibility.ts:104-117`)
115
+ - **Only the write-target bundle is planned** — Candidates come from the run's write target alone (`--bundle`, else `defaultWriteTarget`, else the working bundle), and only while that bundle is writable. Assets in other bundles are never planned. *Why:* reflect reads from the owning bundle but files the proposal in the write target. A candidate from another bundle forked or overwrote assets (#1000). (`eligibility.ts:142-159,204-206`, #1000, `docs/architecture/internals/improve-workflow.md` "Scope restrictions")
116
+ - **Ref scope resolution** — A ref scope resolves only inside the write bundle.
117
+ - A ref owned by another bundle raises `NotFoundError` with a hint to use `--bundle <owner>` or `<owner>//ref`.
118
+ - An unindexed *bundle-qualified* ref whose file exists on disk gives an empty plan with no warning, hint or event (exit 0). A short ref is resolved with `only: <run bundle>` against the index, so an unindexed short ref raises `ASSET_NOT_FOUND` whether or not the file exists, and so does a qualified ref with no file.
119
+ - A resolved ref outside a candidate bundle gives an empty plan.
120
+
121
+ *Why:* #1000. (`eligibility.ts:221-265`, `src/core/asset/resolve-ref.ts:127-141`)
122
+ - **Ref scope bypasses every gate** — An explicit ref skips the signal-delta, ledger-window, retrieval-scope and proactive gates. It is planned with `eligibilitySource:"scope"` and `reason:"scope-ref"`. The distill guards for flagged-wrong, bare-positive and deprecated refs are also overridden. Triage pre-pass is skipped for it. One gate is not bypassed: a ref with a structural validation failure (for example a lesson without a `description`) is dropped before the scope check, so the explicit ref silently plans nothing. *Why:* an explicit ref is usage evidence of its own. (`src/commands/improve/preparation.ts:872-877`, `retrieval-scope.ts:5-13`, `src/commands/improve/loop-stages.ts:302-332`, `improve.ts:849-855`)
123
+ - **Derived memories never enter the reflect pool** — A `.derived` memory is excluded from the planned refs. The loop also hard-skips reflect for any ref ending `.derived` (`derived-memory-reflect-skipped`). Cleanup inspects derived memories on its own. *Why:* "Derived memories are machine-generated: never reflected." (`eligibility.ts:283-285`, `loop-stages.ts:202-206`)
124
+ - **Ref reason attribution** — In non-type scopes, memory entries get reason `memory-cleanup`; everything else gets `scope-type`. (`eligibility.ts:290`)
125
+ - **Unmatched type scope warning (new in 0.10.26101002)** — A type scope that matches no indexed asset (excluding scope `any`) prints one stderr warning: `Scope "<x>" matches no asset in the stash.` It adds `Did you mean <type>?` when the nearest known type is within max(1, floor(len/3)) edits. Exit code and the empty plan are unchanged. A valid scope with nothing eligible does not warn. *Why:* catch typos such as `improve typo`, `skills`, `memorys`. (`eligibility.ts:266-269,315-320`, CHANGELOG 0.10.26101002)
126
+ - **Readonly sources excluded** — `akm improve` and `akm lint` only operate on writable sources. Read-only git, npm and website sources are excluded before any filtering. (`eligibility.ts:204-206`, `docs/architecture/internals/improve-workflow.md` "Scope restrictions")
127
+ - **Dry runs read a non-mutating snapshot** — A dry run opens index.db and state.db through read-only point-in-time copies. A missing or incompatible index yields an explicit empty plan snapshot instead of creating or migrating the database. (`eligibility.ts:62-102,170-178`, `improve.ts:686-715`, `docs/architecture/improvement.md` "Dry-run planning boundary")
128
+ - **Index schema incompatible is an error** — A live run with an index lacking a readable `entries` table throws `INDEX_SCHEMA_INCOMPATIBLE`, advising `akm index`. (`eligibility.ts:66-81`)
129
+ - **Index bootstrap precedes triage and selection** — On a live run `ensureIndex(mode:"blocking")` runs first, with the shared deadline signal. Triage promotions are then indexed incrementally via `indexWrittenAssets`. *Why:* the eligible-ref query reads `entries`, which a DB-version upgrade may just have dropped, and triage promotes into the stash so a later reindex would always find fresh work (code tag R6). (`improve.ts:310-320,590-620`)
130
+ - **Retrieval scope: only rework what is read** — The proactive lane and the consolidate judge (promote pool and pair-pass initiators) may pick only assets that are "used" or "new material".
131
+ - Used: a user-attributed `search`, `curate`, `show` or `feedback` event in the last 90 days (machine traffic does not count; a `.derived` hit counts for its parent).
132
+ - New material: file mtime inside the window, and no improve ledger or proposal row from any stage other than capture (`extract`, `propose`, `remember`, `import`).
133
+ The window is `USAGE_EVENT_RETENTION_DAYS` (90) and has no config key. Refs left out are counted in the `retrieval` gate (`not_retrieved` skip event); consolidate reports them in its warnings. *Why:* on the maintainer's host, 24,051 auto-accepted proposals rewrote 68% of assets, yet 70% of those never appeared in any search/curate result and 95% were never opened (#986). A shorter window would drop assets read less often. (`src/commands/improve/retrieval-scope.ts:1-13,27,91-101`, `src/indexer/usage/usage-events.ts:167-178,201`, `preparation.ts:921-938,1010-1026`, `consolidate.ts:593-598,843-846`, `consolidate/pair-pass.ts:428-433`, #986)
134
+ - **Retrieval scope exemptions** — Fresh feedback and an explicit ref scope need no check. `--require-feedback-signal` (fallbacks off) also skips loading the scope, but for loop selection only: consolidation and the pair pass load their own scope unconditionally. If state.db cannot be read, the scope is `undefined` and every asset stays eligible, with a warning. (`preparation.ts:923-928`, `retrieval-scope.ts:77-82`, `consolidate.ts:593`, `pair-pass.ts:428`)
135
+ - **Consolidate pair-pass ledger rows do not count as "processed"** — They are skipped when building the processed set, so the pair pass's own attempt neither marks material "not new" nor starves the proactive lane. Proposal rows from non-capture sources do count. *Why:* the pair pass judges material against neighbours, not on its own merits (second-review blocker 1, code comment). (`retrieval-scope.ts:60-72`)
136
+ - **Old files can look new** — A bulk rewrite of old files (rename, lint fix, fresh clone) makes them look like new material until improve has processed each once. This is documented as a known effect, but it is the same flood #986 was meant to stop, re-armed by every clone: new material is judged by file mtime (`retrieval-scope.ts:97`), so memory inference's parent stamp, `remember --supersedes` demotion and distill's input-salience stamp also make assets "new" for 90 days. The pair pass uses git first-add time for its own new-material flag but admits its pool through the same mtime scope (`pair-pass.ts:262-327,428,433`). (`docs/architecture/improvement.md` "Retrieval scope", `memory-inference.ts:744-752`, `memory-belief.ts:33-44`, `distill.ts:1235-1239`)
137
+ - **Pre-run structural validation** — Each post-cleanup candidate must have a file on disk (`file not found on disk`). A lesson (`.md`) must have a `description` (`missing description`). Failures are logged and excluded from selection. If validation is enabled and `repairValidationFailures` is not false, an LLM schema-repair runs. A ref leaves the failure set only if a fresh read of the live asset passes. Because the repair only mints a proposal (next rule), a ref normally stays in the `validation` gate until a person accepts it. *Why:* repair is advisory; trust only a fresh re-validation. (`preparation.ts:410-486`)
138
+ - **Schema-repair proposals never edit the live file** — The pre-run LLM repair of a validation failure (missing `description` or `when_to_use`) mints a proposal with `source: "schema-repair"` through the proposal queue instead of writing the asset. A ref is attempted at most once per 7-day ledger cadence (`proposed` row). So `repairedRefs` stays empty in practice: the "schema repair fixed N/M" line is unreachable and the ref stays excluded from the loop until a person accepts the proposal. The proposal carries no `staged` stamp, so the drain defers it (whether anything ever auto-accepts one was not traced). *Why:* M-3 / #387: an LLM write to a field should be human-reviewable. (`src/commands/sources/schema-repair.ts:112,264-282`, `preparation.ts:473-484`)
139
+ - **Disk presence check** — Candidates whose file vanished since planning are dropped and counted in the `disk` gate. One aggregate `asset_missing_on_disk` event is recorded (max 50 refs in metadata). (`preparation.ts:971,1294-1319`)
140
+ - **Plan gate list** — The plan reports gates `profile`, `cleanup`, `validation`, `retrieval`, `signal`, `disk` and `limit`, each with a removed count and a reason. (`improve.ts:815-822`, `preparation.ts:1028-1042`, `planner.ts:56-76`)
141
+ - **Limit ordering** — The ranked list is capped by the effective limit. Reflect-path refs come first, then distill-only refs, each in rank order. So distill-only refs are cut first. There is no lane priority inside the cut: signal-delta refs and proactive picks are merged and sorted by salience alone before it, so with the default limit 25, 20 feedback-bearing refs and 15 proactive picks, ten refs are dropped by salience, and the proactive picks (the 15 highest-salience due assets) can displace refs with fresh negative feedback. This contradicts the lane being a "fallback". (`planner.ts:79-96`, `preparation.ts:945-946,967-969`, `retrieval-scope.ts:10-12`)
142
+
143
+ ### Signals & salience
144
+
145
+ - **Feedback becomes a signal event** — `akm feedback` appends a `feedback` event keyed on the entry's durable `item_ref`. Its metadata carries `signal`, optional `reason`, `tags`, a `contentHash` (body hash) of the text the feedback judged, and `fix` when `--replace`/`--superseded-by`/`--outdated` was used. For a user-attributed event it also updates utility at once. *Why:* the hash lets improve tell whether the body has been edited since the complaint. (`src/commands/feedback-cli.ts:661-665,734-736,776,787-791`)
146
+ - **Negative feedback requires a reason** — Negative feedback without `--reason` is a usage error by default. Setting `feedback.requireReason:false` in akm.json downgrades it to a warning. *Why:* a bare negative says nothing about what is wrong. (`feedback-cli.ts:648-658`)
147
+ - **Utility update (one formula, shared with `akm index`)** (changed in this PR) — `akm feedback` no longer has its own utility formula. For a user-attributed event it calls `recomputeUtilityScores(indexDb, stateDb, entryId)`, the function the `akm index` finalize pass runs for every entry, scoped to this one entry: `utility = prev × 0.7^days + max(selectRate, max(0, pos − neg) / total) × (1 − 0.7^days)`, with `prev` 0 for an entry with no row yet, `days` the time since the last full recompute (at least 1), and only `source='user'` events counted. So the value stored at feedback time is the value `akm index` would compute at that moment; a scoped call skips the 90-day usage-event purge and leaves the `last_utility_computed_at` watermark alone, so the next full `akm index` decays the value one more step, exactly as running `akm index` twice does. A positive therefore raises utility (from 0 to 0.3, then 0.51 on a second), a negative alone only decays it, and one positive plus one negative is rate 0. If a utility at or above 0.5 falls below 0.5, an `improve_review_needed` event is emitted; nothing consumes the event (only its emitter exists). *Why:* two writers with two formulas left the stored value depending on which ran last; the bounded-step EMA it replaced (`FEEDBACK_LR` 0.1 toward the cumulative positive share; MemRL arXiv:2601.03192, F-5 / #386) is deleted with `utility-policy.ts`. `akm search` reads neither: it counts the feedback events itself (see Search: Feedback scales the fused score; changed in this PR, decision 1). (`src/commands/feedback-cli.ts:173-240,834-854`, `src/indexer/indexer.ts:1839-1950`; test `tests/integration/feedback-command.test.ts`, "stores the utility the index finalize pass would compute")
148
+ - **What counts as a signal event** — A feedback event with a string `signal` or a string `note`. Only these count inside the 30-day window. The `note` clause is dead for 0.10's own writer: `akm feedback` has no `--note` flag and writes `signal`, `reason` and `tags`; `note` survives only in the legacy inline-ref grammar. (`preparation.ts:678-684`, `feedback-cli.ts:648-665`, `src/integrations/session-logs/inline-refs.ts`)
149
+ - **Signal window is 30 days** — `FEEDBACK_SIGNAL_WINDOW_DAYS = 30`. *Why:* not stated. (`preparation.ts:678`)
150
+ - **Reflect is planned only from fresh negative feedback** — A ref passes the reflect delta only if it has a negative signal in the window, newer than the reflect ledger row's `lastAttemptAt`, and not blocked by a hard ledger window. Positive and note-only signals never plan a reflect. *Why:* a rewrite is planned from negative feedback only, never from positive signal or on a proactive cadence. (`preparation.ts:855-863`, `docs/architecture/internals/improve-workflow.md` intro and "Ledger pre-filter")
151
+ - **Negative with an applied exact fix does not count** — A negative that came with a `fix` is ignored once a `source:"feedback"` proposal accepted at or after that event exists. *Why:* "already acted on". (`preparation.ts:786-808`)
152
+ - **Distill passes on any signal** — Distill's delta passes on any in-window signal or note, positive included, newer than distill's last attempt and not hard-blocked. (`preparation.ts:858-863`)
153
+ - **Partition into lanes** — Post-cleanup refs are sorted into four groups.
154
+ - eligible: reflect passes. If distill does not pass, the ref is marked distill-cooled.
155
+ - distill-only: reflect fails but distill passes and the ref is a distill candidate.
156
+ - no-feedback pool: no in-window signal and not reflect-ledger-blocked. It is left to the proactive lane.
157
+ - skipped: everything else, i.e. feedback with nothing new, or a live ledger window.
158
+ (`preparation.ts:830-895`)
159
+ - **Soft versus hard ledger windows** — Rejected (`rejected`, `quality_rejected`) and `expired` outcomes are hard windows that fresh signals cannot lift. Every other window is a revisit cadence that a signal newer than `last_attempt_at` lifts. A human `akm proposal reject` of a reflect proposal is therefore a 14-day hard window regardless of the reason text: it blocks new, different negative feedback for two weeks (Reflexion context already stops the same change being proposed again), and only `--scope <ref>` bypasses it. *Why:* newer feedback "lifts a revisit window, never a rejection". (`src/storage/repositories/improve-ledger-repository.ts:131-140,191-196`, `preparation.ts:857`)
160
+ - **Ledger window lengths** — Defaults come from one function, `nextEligibleAt`.
161
+ - Rejected or quality-rejected: reflect 14 days, distill 30 days, any other source 7 days.
162
+ - Expired: 1 day.
163
+ - `unchanged`, `judged_no_action`, `proposed`, `review_needed`: 7 days.
164
+ - `accepted` and `failed`: no window.
165
+ *Why:* the rejection windows are "the constants the rejection backoff always used"; the ledger as a whole replaced the per-stage cooldown constants and event reads that "none of which agreed with each other" (`improve-ledger-repository.ts:1-26,70-82,142-183`). `expired` is a hard window of one day that only matters for the reflect row, and it keeps `last_attempt_at`, so it does not restart the proactive due clock (`improve-ledger-repository.ts:319-321`, `preparation.ts:887-888`).
166
+ - **Content-driven rows have no clock** — The consolidate pair pass (source `consolidate-pair`) never records a `next_eligible_at`. A decided consolidate promotion (`accepted`, `rejected` or `judged_no_action`) that recorded a body hash waits for the body hash to change. Rows with no recorded hash fall back to the clock. *Why:* asking the model about the same text again can only reproduce the proposal (#998). (`improve-ledger-repository.ts:84-129,142-147,172-183`, #998)
167
+ - **Reopen restores the ledger** — Reopening a rejected proposal puts the ledger row back to `proposed` on the 7-day cadence, with the hash cleared. A retire proposal's reopen drops the row its rejection created. (`improve-ledger-repository.ts:344-373`)
168
+ - **Ledger writes are best-effort** — A failed write is a warning, never an error on top of an LLM result. No writes happen in read-only mode. (`src/commands/improve/ledger.ts:87-124`)
169
+ - **Loop attempts that write ledger rows** — A reflect that returns `unsupported_type` or `no_change` records `unchanged`, and a guard rejection (`content_policy_reject`) records `failed`. Quality rejections and proposal mints record themselves. Any other reflect failure (transport error, `parse_error`, `agent_crash`, `spawn_failed`, timeout, a budget abort) writes no row, so the negative feedback that planned the ref still passes the signal delta on the next run (fixed in this PR, #1). A distill that returns outcome `skipped` records `unchanged`; a distill `UsageError` (validation) records `failed`; a distill transport failure leaves no row. Reflect and distill now agree. *Why:* a `failed` upsert moves `last_attempt_at`, and the delta test `feedbackAt > lastAttemptAt` would then park the ref until newer feedback arrived (see Reflect: A transient failure leaves the feedback). (`loop-stages.ts:187,240-266,349-353`, `improve-ledger-repository.ts:262-263`)
170
+ - **Salience rank formula** — `rankScore = (0.25*encoding + 0.15*outcome + 0.60*retrieval) x sizePenalty`, clamped to [0,1].
171
+ - Retrieval is `log(1+freq) x recency`, soft-capped as `x/(x+1)`.
172
+ - Recency halves every 21 days above a floor of 0.1 that itself halves every 180 days, with an epsilon of 0.01.
173
+ - Size penalty is `1/log10(max(200 bytes, size))`, at most 1/log10(200), about 0.43. So `rankScore` never exceeds about 0.43 and the [0,1] clamp is dead.
174
+ *Why:* the 0.25/0.15/0.60 weights are stated as "as it was by default" when the `outcomeWeightEnabled` toggle was removed (#1131). The floor halving is so unused assets "keep drifting down instead of parking at the floor". Other values: not stated. (`src/commands/improve/salience.ts:1-13,22-36,86-113`, CHANGELOG #1131)
175
+ - **Encoding salience defaults by type** — With no content-derived score, the type weight is used: skill 0.9, agent 0.9, command 0.8, workflow 0.8, lesson 0.75, knowledge 0.7, script 0.6, memory 0.5, otherwise 0.5. A stored content-derived encoding score is kept in preference; a type stub never overwrites a content score. (`salience.ts:38-50,88-92,124-127`, `preparation.ts:1174-1180`)
176
+ - **Content-derived encoding salience (distill writes it)** — The score is `0.40*novelty + 0.35*magnitude + 0.25*predictionError`, clamped to [0,1].
177
+ - Novelty is the fraction of body bigrams not in the stash ref/tag vocabulary, floored per type (skill and agent 0.8, memory 0.4, otherwise 0.5).
178
+ - Magnitude counts distinct severity or constraint keywords: 4 or more distinct hits reach 1.0. Without a hit in both buckets it is capped at 0.5.
179
+ - Prediction error is 1.0 for a new asset, else `1/(1+ln(1+revisions))`.
180
+ The scorer is called only from distill. *Why:* issue #608. (`src/commands/improve/encoding-salience.ts:1-51,201-237,249-258`, `src/commands/improve/distill.ts:1234,1269`)
181
+ - **Outcome score (prediction-error EMA)** — For an existing row the new score is `0.3*(retrievalDelta - expectedRate + valence) + 0.7*old`, clamped to [-1.0, 1.5]. The expected retrieval rate is itself a 0.3 EMA of the delta, so old popularity decays out. *Why:* saturation, so a long-popular asset does not accrue unbounded outcome mass. (`src/commands/improve/outcome-loop.ts:5-12,23-31,62-82`)
182
+ - **Outcome warm start** — A new row is seeded from the utility EMA, capped at `WARM_START_CAP` 0.3. *Why:* so the term is not zero at launch, and the first negative delta cannot flip the rank. (`outcome-loop.ts:10-12,26,66-72`)
183
+ - **Outcome-to-salience normalisation** — Negatives clip to 0. The score is divided by the stash-wide maximum (bounded at 1.5) and never falls below 0.1 of that maximum (`DIVERSITY_FLOOR_FRACTION`). *Why:* "below expected is not irrelevant". (`outcome-loop.ts:30-31,105-108`, `preparation.ts:1272-1282`)
184
+ - **Accepted-change count is telemetry only** — `accepted_change_count` is stored but is not a score term. *Why:* as a score term it rewarded churn (#691). (`outcome-loop.ts:5-11`)
185
+ - **Valence feeds outcome only** — `computeValenceScore` gives net valence `(pos-neg)/(pos+neg)` in [-1,1], using all-time counts. Improve uses `.valence` only as an input to the outcome update. *Why:* #614 (symmetric valence weighting). (`src/commands/improve/feedback-valence.ts:54-67`, `preparation.ts:1233`, `src/commands/metrics/collect.ts:389`)
186
+ - **Outcome and salience are persisted only for selected refs on live runs** — Only the merged (planned) refs get outcome rows updated and salience rows upserted. A plan-only run projects the values and writes nothing. (`preparation.ts:1154-1206,1239-1245,1263-1265`)
187
+ - **No-op dampening in selection** — A ref with 3 or more consecutive no-ops sorts at 0.5 of its rank score. The stored rank is untouched. The count rises when reflect returns `no_change`, or distill is `quality_rejected` or `skipped`. It resets when reflect succeeds or distill is `queued`. *Why:* a plasticity counter, so repeated no-ops dampen selection and a change lifts it. (`salience.ts:129-134`, `preparation.ts:958-969`, `loop-stages.ts:153-164,282,357-365`)
188
+ - **Final ordering** — Candidates sort by (dampened) salience descending, then by ref ascending. The cap that follows takes reflect-path refs before distill-only refs (see Limit ordering). (`preparation.ts:963-969`, `planner.ts:84-90`)
189
+ - **Retrieval counts and last-use** — Counts come from `getRetrievalCounts(indexDb, stateDb, refs, {sourceName})`. Last-use comes from the utility `lastUsedAt`. The pass warns if there are no `show` events in `usage_events`, because the retrieval scope would then match only search-retrieved assets. (`preparation.ts:1055-1078`, `salience.ts:136-161`)
190
+ - **Reflect noise gate** — A candidate edit identical up to trailing whitespace is `noop`. One identical after YAML-canonicalising frontmatter and unwrapping prose is `cosmetic`. Neither becomes a proposal. Anything uncertain is `substantive`, because letting a cosmetic edit through costs a review but suppressing a real fix loses work. *Why:* #580 (empty and cosmetic edits each cost an LLM call and a review slot). (`src/commands/improve/reflect-noise.ts:5-37,218-311`, #580)
191
+ - **Reflect defect filter** — A revision is refused unjudged if it adds a placeholder phrase (default list is "please confirm", "please verify", "to be confirmed/determined/verified"), meta-commentary (for example "feedback signal(s)", "this revision", "the source asset/note/memory", "quality gate rejected", "proposal rejected"), or copies a frontmatter key as a body line or copies a provenance value (`sources`, `xrefs`, `contradictedBy`, 12+ chars) into the body. It counts only what the revision adds. `processes.reflect.defectFilter` lists replace a rule's defaults; an empty list turns the rule off. *Why:* on 396 labelled reflect edits (83 good, 313 bad) the default lists hit 22 bad edits and no good one. TODO/TBD/FIXME are deliberately not listed because an asset may carry them on purpose. The measurement was taken on body-rewriting reflect; reflect is now frontmatter-only, so `placeholder_added` and `meta_commentary_added` can still fire (they scan the whole text including frontmatter values), the key-line half of `frontmatter_copied_into_body` cannot (it reads body prose lines only), and the provenance half only in a contrived case. Re-measured on 0.10 and kept: it refused 0 of 420 frontmatter-only edits the two lab models wrote, and on 106 labelled real edits 5 of 78 bad and 0 of 28 good (Policy decision 9). (`reflect-noise.ts:39-184`)
192
+ - **Distill-loop guards (planned-ref level)** — For a planned memory, distill is skipped when any of these hold, unless the scope is an explicit ref. Each skip is recorded as `distill-skipped` with an `improve_skipped` event. The three content guards (flagged wrong, bare positive, deprecated) each record ledger `unchanged`; only `memory_distill_requires_feedback` writes no row. The guards run on the planned refs after the limit cut, so a skipped ref has already used a slot, and the selection-time delta counts a bare positive as a signal.
193
+ - It was reached without a feedback signal (so not as distill-only): `memory_distill_requires_feedback`.
194
+ - It was flagged wrong and not edited since: `distill_flagged_wrong`, ledger `unchanged`.
195
+ - Its only in-window feedback is bare positive with no reason or note: `distill_positive_without_reason`.
196
+ - Its frontmatter `beliefState` is `deprecated` or `superseded`: `distill_deprecated_or_superseded`. `contradicted` is not skipped.
197
+ *Why:*
198
+ - Flagged wrong: a memory flagged wrong is no source for a lesson.
199
+ - Bare positive: 10 of 11 lessons made from such memories were rejected (2026-10-05 review).
200
+ - Deprecated: 26 of ~900 memories had the state and none gave a lesson (2026-10-08 distill-tune run).
201
+ (`loop-stages.ts:285-332`, `preparation.ts:686-747`)
202
+ - **Flagged-wrong test** — A negative in the window flags a memory if it recorded a body hash and the file's body still has it. Without a hash, it flags if the feedback is newer than the file's last write (mtime). So a write that leaves the body alone (an inference stamp or frontmatter repair) does not lift the flag. (`preparation.ts:695-711`)
203
+ - **`distill.requirePlannedRefs`** — If true and every loop ref is distill-only, distill-only refs are skipped (`require_planned_refs`). Shipped default is false. *Why:* not stated. (`loop-stages.ts:98-113,299-301`)
204
+
205
+ ### Proactive lane
206
+
207
+ - **What it is** — A lane that plans stable, useful assets with no fresh feedback, merged with the signal-bearing refs and ranked together. It runs only for `all` or `type` scope, not `ref` scope. It is off if `proactiveMaintenance` is disabled in the strategy or `--require-feedback-signal` is passed. *Why:* measured on 8 planted silent defects with no feedback: 0 fixed with the picks scored only, 6-7 with them planned, no harm, about 5x the model calls (#1129). That measurement was taken with the drain accepting the picks, which #1147 removed (see Its proposals wait for a person); re-measured without the drain on 0.10, the lane is kept at cap 15 (Policy decision 2). (`preparation.ts:1080-1102,925,940-946`, `improve-cli.ts:354-359`, CHANGELOG #1129, #1129)
208
+ - **Candidate pool** — Candidates are the no-feedback pool (no in-window signal, not reflect-ledger-blocked) minus those outside the retrieval scope. (`preparation.ts:932-938`)
209
+ - **Due rule** — An asset is due if never reflected or distilled, or if its last attempt was strictly more than `dueDays` ago. Either a reflect or distill attempt resets the clock. Default `dueDays` is 30 (0 or more). *Why:* it doubles as the rotation cooldown, so each asset comes up at most once per `dueDays`. This is the first part of the cap against a proposal flood. (`src/commands/improve/proactive-maintenance.ts:1-14,22-23,67-86`, `improve-processes.ts:220-225`, #1129)
210
+ - **`maxPerRun` bound** — The top `maxPerRun` due assets are taken (default 15, shipped 15; the code fallback was 25 before #1129 and is now 15). Reflect's `limit` (25) also bounds the run, but the cut is by salience with no lane priority, so the picks can displace feedback-bearing refs (see Limit ordering). *Why:* part of the flood cap. (`proactive-maintenance.ts:25-26,89-92,125-131`, `default.json:14`, `docs/architecture/improvement.md` data-flow step 3, CHANGELOG 0.10.26101001)
211
+ - **Ranking inside the lane** — Priority is the salience rank score computed from type-stub encoding, retrieval frequency, last use and file size. It has no outcome term. Ties break by staleness (never-touched first), then ref ascending. There is no randomisation, so at 15 a day and 30 days between visits at most about 450 distinct assets ever cycle, and among never-retrieved assets of one type priority differs only by file size, so the same smallest files recur. The lane has no memory of futility: a `no_change` reflect records `unchanged` and resets the 30-day clock, and the no-op damper applies only to the merged loop ranking, not to the lane's own priority (`rankScore` is used raw), so an asset reflect already found sound returns at full priority every 30 days. (`proactive-maintenance.ts:94-123`, `preparation.ts:963-966`)
212
+ - **Lane attribution** — Each planned ref is tagged `proactive` < `signal-delta` < `scope`; the strongest lane wins. The tag goes on the dry-run plan, the events, and the proposal as `eligibilitySource`. *Why:* so outcomes can be sliced by lane. (`preparation.ts:948-954`, `src/commands/proposal/proposal-types.ts:15-27`)
213
+ - **Events and plan** — A live run appends a `proactive_selected` event (count, dueTotal, neverReflected). The plan reports `configured`, `effective` (`dueDays`, `maxPerRun`), `candidatePool` and `selectedRefs`. (`preparation.ts:1119-1146`)
214
+ - **What it may propose** — Reflect proposals in practice: reflect revises only `description`, `when_to_use` and the title, never the body. A proactively picked memory is never distilled, because it has no feedback signal (`memory_distill_requires_feedback`; non-memory picks fail distill's type filter); the only link to distill is that a distill attempt resets the lane's due clock. *Why:* on 396 labelled edits, frontmatter-only fixes were good 24/26 times, and edits that also rewrote the body were bad 175 of 224. (`docs/architecture/improvement.md` "Quality judge", `loop-stages.ts:302-310`)
215
+ - **Its proposals wait for a person** — `proposal drain` never accepts a proposal with `eligibilitySource:"proactive"`, whether a deterministic gate, an earlier staged accept or the judgment tier would take it. It is deferred with reason `proactive-needs-review` and is not sent to the judgment tier. Empty diffs are still rejected. Unattended, then, the lane cannot fix anything: the proposal expires after 90 days while the asset is due again after 30, so it is re-reflected at about day 30 and day 60 before the first proposal expires, and up to three identical pending proposals can exist for one asset (nothing blocks a second pending reflect proposal for a ref; see Reflect: Nothing prevents a second pending reflect proposal). *Why:* with the lane on and the drain promoting, reflect edits on assets with no feedback were accepted unattended. The nightly eval showed `when_to_use`, `type` and provenance stamps and a title heading added to assets no one asked about (#1147). (`src/commands/proposal/drain.ts:12-17,593-596`, CHANGELOG 0.10.26101001 "A proposal from the proactive lane waits for a person", #1147)
216
+ - **Unreviewed proactive proposals expire** — A pending proposal older than `archiveRetentionDays` (default 90; 0 disables) is archived as rejected (`expired`) in post-loop hygiene. Counting starts from the last reopen. Retire proposals expire the same way (fixed in this PR, #10). (`src/commands/proposal/repository.ts:872-927`)
217
+
218
+ ### Run governance
219
+
220
+ - **Dry run** — `--dry-run` runs the real selectors on read-only snapshots. It takes no lock, makes no model calls, writes no events, index, state, proposals, sync or run record, and arms no budget watchdog. Its result always goes to stdout. `--show-prompt` implies dry-run. *Why:* so the preview and the live run agree on "what would run". (`improve-cli.ts:407,527-531`, `improve.ts:249-264,323-328`, `docs/architecture/improvement.md` "Dry-run planning boundary")
221
+ - **`--show-prompt`** — It prints reflect's composed prompt for one fully-qualified ref and exits. There is no lock, write or dispatch. It requires a fully-qualified ref scope. *Why:* #952. (`improve-cli.ts:200-229,424-433`, #952)
222
+ - **Whole-run lock** — A live run takes `<locks dir for the stash>/improve.lock`, outside the bundle. The lock is PID-liveness-only: a verifiably dead holder is reclaimed (so is an unreadable sentinel or one with no pid, which `probeLock` also reports stale), with no age-based stale window. A reclaim emits `improve_lock_recovered`. The lock is released in teardown, and also on `process.exit` as a backstop. *Why:* the lock is machine-local state, kept outside the bundle (#890). An age window had stranded a real install in a half-day indexing outage (#872). SQLite's WAL, busy timeout and BEGIN IMMEDIATE already serialise writers, so the lock only avoids duplicate logical work. (`src/commands/improve/locks.ts:1-70`, `src/core/run-lock.ts:5-24`, `improve.ts:267-285,551-552`)
223
+ - **Lock contention behaviour** — Without a flag, contention throws `TransientError IMPROVE_LOCK_HELD` (exit 75, "Delete <lockPath> to force"). With `--skip-if-locked` it warns and returns an exit-0 `ok:true, skipped:{reason:"lock-held"}` envelope. That happens before purge, index, triage, events or sync, and the budget timer is cleared. The skipped envelope still flows through the normal run-record path. *Why:* contention is ordinary and retryable, not a broken config. It had surfaced as exit 78 `INVALID_CONFIG_FILE` and told supervisors to stop retrying (#948). `--skip-if-locked` is for high-frequency scheduled runs; a once-a-night run keeps it off and relies on the frequent task ending first (see Shipped task templates, fixed in this PR, #6). (`locks.ts:43-90`, `improve.ts:267-274,666-684`, `improve-cli.ts:365-370,535-539`, CHANGELOG 0.9.x "Two improve runs colliding", #948)
224
+ - **`--require-engines` pre-flight** — Before any index, lock, or log side effect, it first checks every enabled process has a resolvable engine and credential (`LLM_NOT_CONFIGURED`, exit 78). It then probes each dispatch target. LLM connections get one short completion, once per endpoint plus model. Agent binaries must resolve on PATH. SDK engines need the binary and any LLM fallback. An unreachable target fails the run. The probe timeout is the connection's `timeoutMs` (default `DEFAULT_LLM_TIMEOUT_MS`) capped at 120,000 ms. Each outcome (process, engine, endpoint, latency) is recorded as `engineProbe` on the run result. *Why:* a scheduled run should fail loudly rather than index and then skip everything (#957). A gateway can list a model whose completion route is dead (#980). A fixed 3 s probe failed every scheduled run on 2026-09-27 against a reachable endpoint. The probe is not gated on `--dry-run` or on the lock, so `--dry-run --require-engines` still sends a completion and a `--skip-if-locked` skip still pays the probe and creates its log file. (`improve-cli.ts:58-198,434-438,448-454`, `improve-strategies.ts:208-222`, #957)
225
+ - **Degradation without `--require-engines`** — The run proceeds without the unavailable processes and reports them in `skippedProcesses`. (`improve-cli.ts:371-376`, `improve.ts:1215`)
226
+ - **Wall-clock budget** — `--timeout-ms` defaults to 7,200,000 ms (2 hours). On a live run a watchdog aborts the shared signal at the budget ("improve budget exhausted"). If shutdown has not finished 5 s later, it runs a hook that records the terminated run (`budget_exhausted`) and makes the provenance-scoped auto-sync commit, local only so a dead remote cannot hang the exit, and then force-exits with code 0 (fixed in this PR, #7). The ref loop checks the budget before each ref and, once exhausted, emits `improve_skipped` (`budget_exhausted`, plus a `budget_exhausted_batch` per remaining ref) and an error action `timeout: improve wall-clock budget exhausted`. Post-loop maintenance is skipped. Reflect calls get the remaining budget as their timeout. The result envelope finalizes only if shutdown completes inside that 5 s. Otherwise the forced `process.exit(0)` skips every `finally` and the CLI's exception path, so a hung in-flight call (an agent or SDK subprocess gets a 5 s SIGTERM-to-SIGKILL grace plus 2 s of stream drain) leaves no result and no `improve_completed` event; the hook is what leaves the `improve_runs` row and the local commit of the files the run wrote (the write journal is in memory and would be lost). *Why:* budget exhaustion is "a normal scheduled-task outcome" and exits 0. (`improve-cli.ts:350-353,464-483,505`, `improve.ts:143-185,250-263,457-464,1117-1120`, `loop-stages.ts:215-232,374-407`, `improve-session.ts:71-104`, `src/core/subprocess.ts:150-155`)
227
+ - **First-response heartbeat** — If no engine call has terminated within 5 s of the timer being armed (before preparation, not at loop start, and not at all if triage already produced an engine record), one line `Still waiting for the first engine response...` prints. *Why:* a dead endpoint otherwise looks like a slow run (#957). (`improve.ts:136-141,341-347`)
228
+ - **Signals persist a terminated run** — SIGTERM (exit 143), SIGINT (130) and SIGHUP (129) each write a terminated `improve_runs` row (`ok:false`, `terminated.reason`), then exit. A 2 s watchdog force-exits if persistence hangs. A thrown exception also persists a terminated row, with a redacted error message. *Why:* a cron-timed-out run must not vanish from `akm health`. (`src/commands/improve/improve-session.ts:24-29,71-104`, `improve-result-file.ts:60-131`, `improve-cli.ts:463-483,521-523`)
229
+ - **Autonomy gate** — Improve is on by default but review-first. Unless `experimental.improveAutonomy === true` (absent and false both mean off), three lanes are downgraded.
230
+ - `memoryInference`: set disabled.
231
+ - `triagePromote`: downgraded to `queue`, and only when triage judgment is enabled.
232
+ - `memoryCleanup`: analysed but not applied, checked at the call site.
233
+ The gate runs before the plan is built, so every consumer sees one already-safe strategy. `sync.push` is deliberately not gated (it has its own `--no-push`/`sync.push:false`). *Why:* a blanket experimental gate on the feature would have turned installed schedules into no-ops and removed the only normal producer of memory inference. Deterministic-only promote runs without opt-in because only the judged drain caused harm (#1143, #1132); that rationale is stale as of the same release, because #1147 then found the deterministic drain accepting harmful proactive edits (`CHANGELOG.md:164-170`). (`src/commands/improve/autonomy-gate.ts:5-31,41,98-128`, `src/core/config/experimental.ts:5-43`, `improve-strategies.ts:362-368`, `preparation.ts:551-563`, #1143)
234
+ - **A gated lane is never silent** — Each downgrade warns on stderr with the lane, the reason and the key `experimental.improveAutonomy`. It also appends an `improve_skipped` event (`autonomy_gated`, lane, configKey) and is reported by `akm task doctor` and health. The run's usage report labels the affected no-call process `autonomy_gated`. Because the shipped `default` strategy lists `memoryInference: {enabled: true}`, the gate fires on every live `default` run of an install without the opt-in, which is by design (Policy decision 8, resolved); with `experimental.improveAutonomy: true` it does not fire and the lane runs (on the owner's install, every night 10-09 to 10-11, with 0 `autonomy_gated` events since 09-12). (`autonomy-gate.ts:26-30,54-58,113-116`, `improve.ts:1072-1081`, `improve-usage-report.ts:23-26,68-75`)
235
+ - **Triage pre-pass** — This applies to a live non-ref run with triage enabled. After the lock, purge and index bootstrap, the pending proposal queue is drained with `applyMode` (shipped "promote"), `maxAcceptsPerRun` (shipped 200) and the judgment tier if configured. A failure is non-fatal. It is skipped for a single-ref scope. It runs before consolidation mints, so tonight's staged retirements are accepted tomorrow, and `excludeIds` is always empty in improve (`improve.ts:869`). *Why:* 200 because the pair pass stages about 90 pairs a night and a lower cap let the review queue grow nightly. `default` drains through the deterministic gates because the nightly eval baseline (`proposal drain --promote` without judgment) solved 0.91 (terra) and 0.89 (qwen) with 0 to 1 harmed. (`improve.ts:310-320,849-878`, `default.json:15`, CHANGELOG 0.10.26101002, #1143)
236
+ - **Judgment tier accepts promotions only** — Not an improve rule per se, but the drain this pre-pass runs: the judgment tier may accept only a consolidate promotion; any other kind is left for a person (`judgment-not-promotion`). *Why:* judging all kinds solved ~half the items (0.49/0.53) and harmed 14/11 (#1132). (`drain.ts:20-27`, #1132)
237
+ - **Sync at end of run** — For a git-backed bundle with `sync.enabled` not false, the run commits and, by default, pushes. Resolution is `--sync`/`--no-sync` and `--push`/`--no-push` over the strategy's `sync` block (shipped `enabled:true, push:true`). The default message is `akm improve auto-sync`, with tokens `{timestamp}`, `{date}`, `{time}`, `{scope}`, `{refs}`, `{accepted}`, `{triage_promoted}`, `{triage_rejected}`, `{runId}`. The result carries `sync` and a `stash_synced` event with `phase: "end-of-run"` is recorded (the pre-run sync has its own, see Pre-run sync). (`improve.ts:111-134,1011-1048`, `improve-cli.ts:388-397,441-446`, `default.json:17`)
238
+ - **Sync stages only what the run wrote** — Staged paths are those in the run's write-provenance journal that Git still reports changed. A file someone else edits during the run is left dirty. A file already dirty at the start that the run rewrote is committed. Lock files are never staged. A branch that cannot push (no upstream, or an upstream merge that conflicts) only skips the push, and the result says why. *Why:* stray non-akm files in a shared stash root once tripped the #476 "refusing to push" guard on every end-of-run sync for ~1.5 days; staging was then scoped (CHANGELOG 0.9.0-rc.1, ~7266), and #652 is the write-provenance journal. (`improve.ts:383-394,911-1047`, `docs/architecture/improvement.md` "Auto-sync", #652)
239
+ - **Sync never throws; crash path commits too** — `commitStashBatch` is idempotent and best-effort (failure is a non-fatal warning). It also runs in the failure path, so an exception commits what the run already wrote. A signal exit records the terminated run row and exits without a sync. A budget hard-kill makes the same provenance-scoped commit with the push turned off before it exits 0, so the files a killed run wrote are not left uncommitted (fixed in this PR, #7). When only the push fails, `commitStashBatch` reports `committed:true, pushed:false, skipped:true` and `stash_synced` records the commit that landed (`GitStashPushError`, fixed in this PR, #5); any other sync error still reports `committed:false`. *Why:* #662 (crash/abort safety net, so an interrupted run does not leave an uncommitted backlog); the journal is in memory, so a forced exit has to commit before it goes. (`improve.ts:250-263,363-370,1011-1049`, `improve-session.ts:74-89`, `src/sources/providers/git-stash.ts:154-162`, `src/core/write-provenance.ts:61-66`)
240
+ - **Pre-run sync (changed in this PR, decision 7)** — A live run on a git-backed write-target bundle calls the same `saveGitStash` sync as `akm sync` right after it takes the lock, before the archive purge, the index bootstrap and the plan: it commits pending managed-path edits (so they are not mistaken for this run's writes), fetches, fast-forwards or merges the upstream, and pushes per the run's push setting (`--no-push`, `--push`, `sync.push`). `--no-sync`, `sync.enabled: false`, a non-git bundle and a dry run skip it. A bundle whose content sits in a repo subdirectory stages only that subdirectory. The outcome is `result.preRunSync` and a `stash_synced` event with `phase: "pre-run"` (the end-of-run event carries `phase: "end-of-run"`). A conflict or fetch failure only warns (`[improve] pre-run sync: <reason>; continuing on the local state`) and the run continues on local state; a merge that conflicts is aborted by `akm sync` and the local commit kept. There is no agent hand-off for a conflict. *Why:* #1091 says every improve process syncs before it reads the bundle, so the index and plan see what another host pushed. A directory that is not a git repository (for example a plain `filesystem` bundle) is not synced. (`src/commands/improve/improve.ts:196,286-288,364,915-1004`, `src/core/improve-types.ts:1020-1026`)
241
+ - **Archive purge sweep** — The first thing after the lock and the pre-run sync is `purgeGracedArchive`, which is deterministic and uses no LLM. For git-backed bundles only, it deletes the archived asset files (never `cleanup.md`) of retirements whose tombstone `retiredAt` is more than 30 days old (`RETIRE_GRACE_DAYS`) and whose archive directory is entirely git-tracked and clean. A bundle with no `.git` is left untouched; `akm health` reports its size instead. After the purge `akm proposal revert` of that retirement restores the primary from the proposal's `backupContent` instead of the archived copy (see Proposals and feedback: Revert; fixed in this PR, #11). *Why:* so the purge only ever removes bytes git can already recover. (`improve.ts:293-308`, `src/commands/improve/memory/memory-improve.ts:708-710`, `docs/architecture/improvement.md` "Archive purge")
242
+ - **Write-provenance journal** — The run opens a journal (#652) right after the pre-run sync, so the sync's commit is not counted as a write of the run. It records every file the run mutates, and `writtenPaths` is placed on the result. (`improve.ts:197-202,291,359-363`)
243
+ - **Run record in `improve_runs`** — The run id is minted up front as `<ISO with dashes>-<8 hex>`, so a killed run still leaves a row, including the budget hard-kill path, whose hook records a terminated row with reason `budget_exhausted` before exiting (fixed in this PR, #7). A live run's full result, redacted of configured engine credential values, is stored as one `improve_runs` row (id, started and completed times, stash dir, dry-run flag, strategy, scope, guidance, ok, result). Stdout stays empty unless `--json-to-stdout` is passed. If no writable bundle directory resolved, it warns that the result is not persisted. A dry run persists nothing. *Why:* since 0.8.0 stdout is empty unless asked; state.db is the durable record. Writes use BEGIN IMMEDIATE with retry because a bare write surfaced "database is locked" (#948). (`improve-result-file.ts:5-58`, `improve-cli.ts:454-459,464-483,505,533-556`)
244
+ - **`--json-to-stdout`** — It emits the full persisted run result on stdout in addition to the record. (`improve-cli.ts:360-364,555`)
245
+ - **Per-run log file** — A live run mirrors stderr progress to `$CACHE/logs/improve/<ISO>.log` (and `akm index` writes `$CACHE/logs/index/<ISO>.log`). Both directories come from one helper, `getRunLogDir`, and both are purged by the retention pass after `improve.eventRetentionDays` (see Retention purges; fixed in this PR, #14). *Why:* the files sat outside the retention purge and grew without bound. (`improve-cli.ts:448-454`, `loop-stages.ts:787-797`, `src/core/paths.ts:410-413`, `src/commands/sources/stash-cli.ts:124`, `src/tasks/run/task-log.ts:105-137`)
246
+ - **Lifecycle events** — `improve_invoked` (preparation, live only), `improve_skipped` aggregates, `improve_completed` (per-mode counters, durations, proactive and cleanup stats) and `improve_failed` (redacted error) are appended. (`preparation.ts:535-549`, `improve.ts:559-573,1255-1264,1281-1361`)
247
+ - **Usage report** — The run builds an LLM usage report from the records its own sink collected (so other akm processes' calls never count). It is a process x engine x model cross-tab plus enabled processes that made no call, with a reason. The reason priority is `engine_unavailable`, then (disabled and lane gated) `autonomy_gated`, then `strategy_filtered_all_passes`, then the dominant skip reason, else `no_signal`. It prints to stderr after the run and is available via `akm improve report [--run|--since]`. *Why:* #944. (`src/commands/improve/improve-usage-report.ts:1-132`, `improve.ts:1162-1172`, `improve-cli.ts:231-253,550-553`, #944)
248
+ - **`--limit`** — It caps refs processed, highest salience first. See limit ordering. (`improve-cli.ts:349`)
249
+ - **Dry-run `plan.processes` routing table** — A table of per-process engine, model, notices, unavailability and eligible-ref counts. It is a pure projection of the resolved plan, shared with `akm health`. *Why:* #947, #944. (`improve-strategies.ts:241-322`)
250
+
251
+ ### Maintenance
252
+
253
+ - **Order of post-loop work** — Preparation order (before the loop) is: consolidation, session extract, memory cleanup, validation, lint, then candidate selection. After the loop, a live run does memory inference, then indexes inference's written files, then proposal hygiene (orphan purge, then expiry), orphan-state GC, retention purges, and finally the dead-URL check. All of this is skipped if the budget is aborted or exhausted. *Why:* the order is code structure; no reason stated. (`improve.ts:1116-1134`, `loop-stages.ts:412-479,505-572`, `preparation.ts:512-623`)
254
+ - **Proposal orphan purge** — Pending proposals whose target asset no longer exists are rejected. A `proposal_orphan_purge` event is emitted. (`loop-stages.ts:657-696`)
255
+ - **Proposal expiry** — Pending proposals older than `archiveRetentionDays` (default 90; 0 disables) are archived as rejected (`expired`, gate `retention`), with a `proposal_expired` event per proposal and a roll-up `proposal_expiration_pass`. The ledger records `expired` with a 1-day grace, not a rejection window, because nobody judged the content. Retire proposals expire like any other (fixed in this PR, #10). *Why:* `akm proposal reopen` (#997) brings an expired retire proposal back while its files and hashes are still fresh, and an unexpired one would hold its pair (the pair pass skips any pair touching a pending retire's retired ref or successor) for good. (`loop-stages.ts:698-724`, `repository.ts:872-927,2280-2290,2327-2342,2392`, `improve-ledger-repository.ts:79-80`)
256
+ - **Orphan-state GC** — Rows in `asset_salience` and `asset_outcome` whose ref no longer resolves in index.db are stamped `missing_since`. The stamp is cleared if the ref resolves again. Rows stamped longer than 7 days (`STATE_GC_GRACE_MS`) are deleted only if `improve.stateGc.collect` is true (default off). An `asset_state_gc` event is emitted if anything is pending or collected. A source whose index rows are still present never surfaces candidates. *Why:* renames and deletions strand state rows, a demonstrated field failure that needed manual SQL (#733). Deletion defaults off so live data can prove the report clean first. (`loop-stages.ts:802-903`, `improve.ts` schema `src/core/config/schema/improve.ts:18-33`, #733)
257
+ - **Retention purges (default 90 days, not 30)** — `improve.eventRetentionDays` defaults to 90, and 0 disables the purge. It trims state.db events, `improve_runs` rows, logs.db `task_logs`, and per-run task log files under the task log dir (`$CACHE/tasks/logs/<taskId>/*.log`); and the flat per-run log files under `$CACHE/logs/improve/` and `$CACHE/logs/index/` (event `run_log_files_purged`; fixed in this PR, #14). It then VACUUMs state.db if enough pages are reclaimable. Each store fails independently. state.db work borrows the run's long-lived handle, because a second writer on the same WAL locks (#585). *Why:* bound append-only observability growth. Per-run task log files were previously never cleaned (#951), and the improve and index run logs were not either. (`loop-stages.ts:727-800`, `src/storage/repositories/improve-runs-repository.ts:271-277`, `src/core/config/schema/improve.ts:38`, `docs/reference/data-and-telemetry.md:136`, #951, #585)
258
+ - **Usage-event retention** — The usage log, which feeds the retrieval scope, is purged to 90 days by `akm index` (`USAGE_EVENT_RETENTION_DAYS`), not by improve. (`src/indexer/usage/usage-events.ts:201`, `src/indexer/indexer.ts:1845`)
259
+ - **Lint pass** — `akmLint({fix:false})` runs on the primary stash during preparation. The result is summarised as `lintSummary` (fixed, flagged). A lint failure never blocks improve. (`preparation.ts:607-615`)
260
+ - **MEMORY.md budget warning** — If `memories/MEMORY.md` has 180 or more lines (budget 200), a warning "Consolidation strongly recommended" is added and `memoryIndexHealth.overBudget` is set. *Why:* index-file size budget; no further reason stated. (`preparation.ts:657-674`)
261
+ - **Dead-URL check** — For `all` scope only, with actionable refs and a primary stash, every actionable knowledge ref's body is scanned for dead URLs. Network concurrency is bounded inside `checkDeadUrls`, and the result and coverage are recorded. Best-effort. *Why:* #892. (`loop-stages.ts:430-467`, #892)
262
+ - **Memory-inference gate** — The pass is skipped if disabled by the strategy or if pending parents number fewer than `minPendingCount`. It discovers all pending parents regardless of which refs were touched this run. Files it writes are indexed immediately. (`loop-stages.ts:595-655,535-555`)
263
+ - **Memory cleanup application** — (Rules for the lane are in Memory cleanup.) If the autonomy lane is allowed, the cleanup plan is applied and archived refs are dropped from the planned set. A reindex follows any archive or belief-state transition. Without autonomy, the plan is analysed only and the `cleanup` gate reports "memory cleanup archive application is autonomy-gated". (`preparation.ts:551-594`, `planner.ts:56-76`)
264
+
265
+ ### Code and doc mismatches
266
+ - **Retention window** — The brief says purges of events, `improve_runs` and `task_logs` happen "older than 30 days". The code default is 90 days (`improve.eventRetentionDays`, 0 disables). The 30-day values in this area are `RETIRE_GRACE_DAYS`, `FEEDBACK_SIGNAL_WINDOW_DAYS` and the default `dueDays`. (`loop-stages.ts:738`)
267
+ - **No-op damping comment** — The header comment of `salience.ts:7-8` says `consecutive_no_ops` damps "consolidation selection only". The code applies it to the improve loop ranking (`preparation.ts:963-966`), and the workflow doc agrees.
268
+ - **Valence comment** — The header of `feedback-valence.ts` describes a "gated, default-off" attention term. In 0.10 only `.valence` is consumed, by the outcome update, and `attention` is not used.
269
+ - **Flowchart order** — The flowchart in `docs/architecture/internals/improve-workflow.md` draws triage before `ensureIndex`. The code runs `ensureIndex` first, then triage (`improve.ts:310-320`).
270
+ - **Consolidate description** — `consolidate.json`'s "every 4h" is accurate in context (a strategy cannot encode a schedule; the shipped task `improve/akm-improve-consolidate.yml` is the every-4h schedule). The stale part of that description is "merges, deduplicates, and prunes memories": the promote pass is promote-only and the merge/delete operations were removed (`consolidate.json:2`).
271
+
272
+ ## Reflect and quality gates
273
+
274
+ All paths are relative to the `rules-010` worktree. Citation form is `path:line`. Where a reason comes from the CHANGELOG rather than a code comment, the section is named.
275
+
276
+ ### When reflect runs
277
+
278
+ - **Reflect is planned only from fresh negative feedback.** A ref enters the reflect lane only if it has an in-window `negative` feedback event, newer than the ref's last reflect attempt in the improve ledger, and not blocked by a ledger window. Positive-only or note-only feedback never plans a reflect (it can still plan distill). *Why:* "Reflect's signal delta reads negative feedback only"; a positive records that an asset helped and must not trigger a rewrite (CHANGELOG 0.9.21). (`src/commands/improve/preparation.ts:858-866`, `:841`, `:807`; `docs/guides/improve-the-library.md:106`)
279
+ - **Signal window is 30 days.** Only feedback events carrying a `signal` or `note` count, and only those within 30 days. A bare `akm feedback` with neither counts for nothing. *Why:* not stated in code/docs beyond the constant. (`preparation.ts:678-684`)
280
+ - **Negative feedback with an already-accepted exact fix is ignored.** A negative event with a `fix` (`--replace`, `--outdated`, `--superseded-by`) is dropped from the reflect cursor when an accepted `feedback`-source proposal for that ref was created at or after it. *Why:* on 2026-10-07, 44 of reflect's 50 refs were of that kind, and the judge refused them or the model changed nothing. (`preparation.ts:795-810`; CHANGELOG 0.9.27-alpha.2 "Reflect no longer plans an asset whose negative feedback is already acted on")
281
+ - **A transient failure leaves the feedback that planned the ref.** A reflect failure that is not a verdict on the asset (transport error, `parse_error`, `agent_crash`, `spawn_failed`, timeout, a call cut by the budget) writes no ledger row, so `last_attempt_at` does not move. The delta test is `feedbackAt > row.lastAttemptAt`, so the ref is planned again on the next run from the same negative feedback. Only a guard rejection records `failed`; `unsupported_type` and `no_change` record `unchanged`; a quality rejection records itself. Distill behaves the same way: a transport failure writes no row (fixed in this PR, #1). *Why:* a `failed` row would advance the cursor, and one dead endpoint for one night would park every planned ref (and, because the ref has feedback, keep it out of the proactive pool) until newer feedback arrived. (`preparation.ts:858-863,885-892`, `loop-stages.ts:252-266`, `improve-ledger-repository.ts:262-263`, `loop-stages.ts:349-353`)
282
+ - **Newer feedback lifts a soft revisit window, never a rejection.** `isLedgerBlocked(row, now, feedbackAt)` ignores a signal newer than the last attempt for soft windows. For `rejected`, `quality_rejected` and `expired` rows the block holds regardless. *Why:* code comment "Newer feedback lifts a revisit window, never a rejection". (`preparation.ts:857`; `src/storage/repositories/improve-ledger-repository.ts:131-140,191-196`)
283
+ - **Ledger windows set the cooldowns.** Reflect `rejected` or `quality_rejected` blocks 14 days (distill 30, other sources 7). `expired` blocks 1 day. `unchanged`, `proposed`, `review_needed` and `judged_no_action` block 7 days. `accepted` and `failed` have no window. *Why:* "the constants the rejection backoff always used". The ledger replaced several projections that "none of which agreed with each other". (`improve-ledger-repository.ts:6-18,70-82,142-163`)
284
+ - **Explicit `--scope <ref>` bypasses all gates.** Signal delta, ledger window and retrieval scope are skipped, and the lane is tagged `scope`. *Why:* explicit intent is its own evidence. (`preparation.ts:833-834,875-878,950-953`; `src/commands/improve/retrieval-scope.ts:7-12`)
285
+ - **Proactive-maintenance lane also feeds reflect (on in `default`).** Assets with no fresh feedback are planned if due: never touched, or last reflected or distilled more than `dueDays` (default 30) ago. At most `maxPerRun` (15) are picked, ranked by salience, then staleness, then ref. They go to reflect with lane `proactive` (they are never distilled: a pick has no feedback, so the loop skips distill with `memory_distill_requires_feedback`), and `--require-feedback-signal` turns the lane off. *Why:* measured on 8 planted silent defects with no feedback, 0 fixed with picks scored only versus 6-7 when planned, no harm, about 5x the model calls (#1129). The cap is "the cap against a proposal flood". The measurement assumed the drain accepted the picks, which #1147 removed; re-measured without the drain on 0.10, the lane finds most silent defects and a person decides on each one, so it is kept at cap 15 (Policy decision 2). (`src/commands/improve/proactive-maintenance.ts:5-14,23,26,67-86`; `src/assets/improve-strategies/default.json:14`; `preparation.ts:924,944-952`; CHANGELOG 0.10.26101001 "Changed", #1129)
286
+ - **The proactive lane only picks assets retrieval returned, or new material.** Without feedback or an explicit ref, an asset must have been returned by user `search`, `curate` or `show` in the last 90 days (the usage-log retention), or be newly captured material improve never processed. *Why:* in July the proactive lane rewrote 3,069 assets, 3,059 of which had never been retrieved; the pool dropped from 15,686 to 6,450 (#986). (`retrieval-scope.ts:1-12`; `preparation.ts:925-935`; `src/indexer/usage/usage-events.ts:201`; CHANGELOG 0.9.17)
287
+ - **There is no explicit `no_signal` reason, but refs with nothing new are dropped and counted.** Candidates not surviving the signal delta are logged as `distill-skipped`, with reason "no new signal since last proposal" or "not retrieved inside the usage window". Aggregate `improve_skipped` events are `no_new_signal` and `not_retrieved`. The plan gates are `retrieval`, `signal`, `disk` and `limit`. (`preparation.ts:991-1013,1031-1045`)
288
+ - **Reflect `limit` is 25 and is the run cap.** `--limit` beats `processes.reflect.limit`, which beats the strategy `limit`. Candidates are ranked by salience, and a ref whose last 3 runs were no-ops sorts at 0.5x its score. *Why:* "reflect's `limit` (25) bounds the run" against proposal flood. The no-op dampener is described as consolidation-selection only in salience.ts, yet `selectLoopCandidates` applies it to the reflect/distill loop too. (`src/commands/improve/improve.ts:477-485`; `preparation.ts:956-969`; `src/commands/improve/salience.ts:129-134`)
289
+ - **Backing file must exist on disk.** Refs whose file is missing are dropped (`disk` gate). (`preparation.ts:971,1031-1045`)
290
+ - **Structural validation pre-pass excludes failing refs.** Before the loop, each candidate must exist on disk, and a lesson must have a `description`. Failures get an optional LLM schema repair, accepted only if a fresh read passes. Refs still failing are excluded from the loop. *Why:* not stated beyond "advisory repair". (`preparation.ts:409-485,647`)
291
+ - **Strategy filters.** Reflect `allowedTypes` defaults to agent, command, knowledge, lesson, memory, skill and workflow. `excludeRefPrefixes` is reflect-only. A disabled process skips. A ref both reflect and distill would refuse is dropped as `strategy_filtered_all_passes`. *Why:* R12: raw wiki-ingest snapshots are type `knowledge`, so type filtering alone can't exclude them. (`src/commands/improve/improve-strategies.ts:36-40,56-83,98-100`; `src/core/config/schema/improve-processes.ts:48-60`)
292
+ - **`.derived` memories are never reflected.** They are machine-generated. (`src/commands/improve/eligibility.ts:284`; `loop-stages.ts:201-206`)
293
+ - **The run only improves its own write-target bundle.** Candidates come only from the writable bundle the run writes to. *Why:* reflect reads from the owning bundle but files the proposal in the write target, so another bundle's asset would be read from one and filed in another (#1000). (`eligibility.ts:142-148,204-206`; `src/commands/proposal/repository.ts:410-416`)
294
+ - **Refuses before calling the model when the file is not where a proposal would write.** A skill's `references/a.md` is indexed as `knowledge/skills/<name>/references/a`, so a proposal would be a `create` and accepting it adds a second file. The result is `unsupported_type` with subreason `file_outside_layout`, recorded as a skip. *Why:* #1052. (`reflect.ts:714-736,778-786`; CHANGELOG 0.9.27-alpha.1)
295
+ - **Reflect time budget.** Reflect gets `timeoutMs` equal to the improve run's remaining budget, and the shared abort signal. With no timeout set it uses `DEFAULT_LLM_TIMEOUT_MS` (600,000 ms). A call cut by the budget returns a failure and writes no ledger row, so the feedback still plans the ref next run (see A transient failure leaves the feedback); the abort message is also kept out of "Avoid These Patterns" (fixed in this PR, #1). *Why:* "Model work is bounded". (`loop-stages.ts:214,230-232`; `reflect.ts:507-513`; `src/integrations/agent/config.ts:9`)
296
+
297
+ ### What reflect may change
298
+
299
+ - **Only `description`, `when_to_use` and a title. The body text is the source's own, except that a title adds a `# title` heading.** The engine returns `confidence` plus `frontmatterPatch {description, when_to_use, title}`, each a non-empty single-line string or `null`. akm applies the patch itself. When the patch carries a title and the body has no level-1 heading, akm prepends `# <title>` and strips every leading blank line of the body, so the body is not byte for byte unchanged; #1147 lists exactly this edit ("a title heading added to assets no one asked about") as harm. After the judge, the proposal mint also splices `type:` and `updated:` lines into the frontmatter when they are missing (next rules and Proposal outcome). *Why:* on 396 labelled reflect edits, frontmatter-only fixes were good 24 of 26 times, and edits that also rewrote the body were bad 175 of 224. (`reflect.ts:5-10,229-235,237-259,313-322,428-446`; CHANGELOG 0.9.25-alpha.3)
300
+ - **A title becomes a `# title` heading only if the body has no level-1 heading.** Otherwise the title is ignored. The test is a multi-line regex that also matches a `# comment` line inside a fenced shell block, which suppresses the title. (`reflect.ts:257-259`)
301
+ - **Only the changed keys' frontmatter lines are rewritten.** Every other line is kept, and an unchanged-equal field is dropped from the patch. The mint then adds `type:` and `updated:` lines (see Mint below). *Why:* a source whose YAML the parser reads only partly (a broken description beside a list) must lose nothing. (`reflect.ts:243-245,274,289-304`)
302
+ - **A source whose frontmatter fence is opened but never closed gets no proposal.** It is reported as `no_change` ("empty diff"), recorded `unchanged` and counted by the plasticity counter; the person the comment says should fix it is never told. *Why:* it would leave the old block in the body under a new one, so a person fixes it. (`reflect.ts:253-255,1042-1059`)
303
+ - **Missing required description is derived from the asset's own text.** If the source has frontmatter, its type requires a description (lesson, knowledge, memory, skill, command, agent, workflow or fact), and neither source nor patch has one, akm derives one from the title, first heading or first prose sentence. The candidate must pass `isValidDescription`; it is never free-form. That holds for the derived description only: a model patch on a source with no frontmatter gets a new block (`reflect.ts:280-283`). *Why:* the promote-time validator (20-400 chars) rejected ~14 of 16 scraped-doc proposals (#636). Injecting a whole block or overwriting an authored one is out of scope. (`reflect.ts:196-218,261-273`; `src/core/authoring-rules.ts:32-37,71,90`; CHANGELOG ~7225)
304
+ - **Allowed types.** Knowledge, memory, lesson, skill, agent, command and workflow are allowed. Another type is allowed only if its current content is already frontmatter + markdown. `secret` is always refused, and its content is never read or sent to an LLM. Refusals are `unsupported_type`, with the message "Use `akm proposal new` or edit the file directly". *Why:* the output is frontmatter + markdown, which would break a script or env file. (`reflect.ts:158-173,744-777`)
305
+ - **A patch that changes nothing creates no proposal.** The `no_change` result applies when every field is `null` or equal to the source's. *Why:* reflect may answer "nothing to change"; the old prompt forbade it, and edits that fixed no defect were bad 86 of 93 times. (`reflect.ts:275,1046-1060`; CHANGELOG 0.9.25-alpha.3)
306
+ - **Noise gate: noop and cosmetic edits are dropped before any judge call.** `noop` means identical up to trailing whitespace. `cosmetic` means identical after normalising frontmatter as parsed YAML (keys sorted) and prose as unwrapped text. Code fences and indented code compare verbatim. Anything uncertain is `substantive`. Subreasons are `reflect_skipped_noop` and `reflect_skipped_cosmetic`. *Why:* "Empty and cosmetic-only edits... each cost an LLM call and a review slot." It is conservative: "letting a cosmetic edit through costs a review, suppressing a real fix loses work" (#580). (`src/commands/improve/reflect-noise.ts:5-37,225-305`; `reflect.ts:1020-1060`)
307
+ - **Prompt constraints on what to change.** The prompt says to change a field only if missing, broken, or claiming more than the body covers. Feedback is "a signal, not a fact to insert". Feedback about a task the asset never claims to cover needs no change. For stale or superseded notes, `when_to_use` names the version or date the body records. With no feedback the prompt says to fix only a missing or broken field. *Why:* models invented sections (incident dates, ports, disk layouts) when feedback asserted a claim (#952); most negative feedback is a retrieval miss, not a defect. (`src/assets/prompts/reflect-feedback-framing.md`; `src/integrations/agent/prompts.ts:333-362`; CHANGELOG 0.9.25-alpha.3, #952)
308
+ - **Prompt names the frontmatter problems akm can see.** These are a description split by a stray period, an escaped quote, no `when_to_use`, and no title; the repair keeps the description's wording, names, numbers and paths. *Why:* without the list the model fixed 51 of 122 broken descriptions and 93 of 193 missing titles; with it, 120 and 189. (`prompts.ts:243-289`; CHANGELOG 0.9.25-alpha.3)
309
+ - **Content cap in the prompt.** The asset is capped at 12,000 chars (`REFLECT_CONTENT_CAP`) with a truncation marker. A direct-LLM runner may use half its context window after prompt overhead, never below 12,000. *Why:* the flat cap keeps argv under OS ARG_MAX for opencode/claude (E2BIG). The marker is exported so validators can refuse a body that echoes it (#952). (`prompts.ts:94-111,383-398`; `reflect.ts:859-869`)
310
+ - **Reflexion context: the 3 newest content-based rejections for the ref are shown.** The prompt says not to propose the same change again, and to return `null` if no other change is justified. Each shows the reason and a 500-char preview. Procedural rejections (stale target, expiry, asset missing) are left out. *Why:* "proposals that have already been reviewed and refused". Procedural refusals "are not judgements on the content". (`src/commands/improve/stage.ts:209-236`; `src/commands/proposal/proposal-types.ts:346-357`; `prompts.ts:400-416`)
311
+ - **Recent feedback is shown, capped.** Up to 10 lines per target, or 20 across all assets without a ref. A line given on a different body hash is marked "(given on an earlier version of the text)". The lines are the last events of any age: the 30-day window and the acted-on filter apply only at planning, and unscoped runs prefix each line with the ref. *Why:* the feedback judged text the asset may no longer have. (`reflect.ts:124-156`)
312
+ - **Recent reflect errors become "Avoid These Patterns" (rolling window of 3).** The section is run-only guidance. Accept-time validation warns if the heading appears in the proposal. *Why:* the section is run diagnostics, not asset content. A person accepting can't edit the proposal (#963). (`preparation.ts:161-167`; `loop-stages.ts:213-226`; `src/core/content-safety.ts:8-19`; `proposal-quality-validators.ts:347-362`)
313
+ - **Lesson lint findings are supplied as schema hints.** (`reflect.ts:175-179`)
314
+ - **Self-refine passes: default 1.** `maxRefineIters` is clamped to at least 1. Later passes critique the prior draft and stop early on an unchanged draft. The improve loop never sets it, so improve always runs 1 pass. *Why:* R-1 / #372 (Self-Refine). (`reflect.ts:113-114,950,962-1002`)
315
+
316
+ ### Quality gate & judge
317
+
318
+ - **The judge is on by default; set `processes.reflect.qualityGate.enabled: false` to turn it off.** Each gate (reflect, distill) follows only its own switch. *Why:* 0.9.23 fixed a bug where reflect's gate ran if either gate was on. (`reflect.ts:1222`; `src/core/config/schema/improve-processes.ts:66-88`; CHANGELOG 0.9.23)
319
+ - **Judge engine resolution.** The order is: the gate's own `engine`/`model`/`timeoutMs`/`llm`, then the reflect runner if it is an LLM engine, then the `reflect_proposal_quality-judge` process engine. A gate naming settings that resolve to no engine throws before anything is generated, with no fallback. A gate that names nothing takes the reflect runner or the process engine. A judge whose credential is missing at run time throws for each planned ref, which the loop reports as `mode:"error"` with no ledger row and no `engine_unavailable` skip: plan resolution checks process runners only, never `qualityGate` engines. *Why:* #1011. "The gate never falls back to another judge." (`reflect.ts:1221-1235,1243-1244`; `stage.ts:311-345`; `improve-strategies.ts:434-454`; `loop-stages.ts:185-195`)
320
+ - **No judge available means the proposal is queued for review, not rejected.** The review reason is `no-judge-configured`, with a warning. (`reflect.ts:1236-1242,1118-1122`)
321
+ - **Three criteria on a 1-5 scale: NEED, PRESERVATION, QUALITY.**
322
+ - NEED: every changed field fixes a real problem. Score 1-2 if any changed field rewrites a sound field.
323
+ - PRESERVATION: the new description keeps every fact; all other frontmatter is unchanged. Score 1-3 if anything is dropped.
324
+ - QUALITY: every new value is supported by the body. Score 1-2 for an unsupported value, a truncated fragment, or a stale-note `when_to_use` that doesn't name the version or date.
325
+
326
+ *Why:* the rubric was tuned for frontmatter-only revisions on 113 reviewed proposals (rubric f07). The production model passed 78 of 95 good and 3 of 18 bad, against 53 and 7 under the earlier body-rewrite rubric. (`stage.ts:458-510,526`; CHANGELOG 0.9.25-alpha.4)
327
+ - **Pass band: every criterion must be at least 4.** Otherwise a mean of 2.5 or more is "review" and below 2.5 is "reject". A unanimous 4+ is required because judging on a mean (3.5) let rewrites that only reworded a correct asset through. *Why:* CHANGELOG 0.9.21. Note the following for reflect. (`stage.ts:582-592,643`)
328
+ - **A reflect non-pass with a mean of 3.5 or more goes to a person; a lower one is refused (changed in this PR, decision 4).** `judgeFailed` is true only for `reviewNeeded && score === -1`. `judgeUncertain` is `reviewNeeded` with a score at or above `REFLECT_REVIEW_MIN_SCORE` (3.5). A judge-uncertain revision is minted as a pending proposal, stamped `deferred`, gate `quality-gate`, reason `judge-uncertain`, with the judge's scores and reason attached, and records no `quality_rejected` window. A non-pass with a real score below 3.5 (the judge labels 2.5 to 4 as review) still goes to `refuse(...)`: no proposal, `quality_rejected`, 14-day hard window. *Why:* on labelled judge runs (akm-eval judge-gate, lab 27B Q2) the shipped judge's non-passes with a mean of 3.5 or more were 17 good and 9 bad among 113 frontmatter-only proposals, while below 3.5, 396 labelled body rewrites were 6 good and 285 bad; so the band is worth a person's time and what is below it is not. (`reflect.ts:1004-1018,1091-1116,1118-1124`, `stage.ts:643`)
329
+ - **The lesson-only "reject if any criterion at or below 2" rule does not apply to reflect.** It triggers only when a `grounding` criterion is present. (`stage.ts:522-523,638-642`)
330
+ - **A judge failure routes to review rather than rejection.** A timeout, error or unparseable reply gives `score: -1` and `reviewNeeded: true`. Reflect creates the proposal pending, with a gate decision `deferred`, reason `judge-error`, gate `quality-gate`. *Why:* reflect used to record `quality_rejected`, blocking the asset for 14 days over a judge outage. (`stage.ts:631-635`; `reflect.ts:1102,1118-1148`; CHANGELOG 0.9.24)
331
+ - **The judge runs at temperature 0 with thinking off.** The judge only thinks if its own engine sets `enableThinking: true`. *Why:* 0.9.17 pinned temperature 0 because at the previous effective default of 0.3, 10 of 16 identical inputs had flipped verdict (CHANGELOG 0.9.17). A thinking judge passed more good and more bad edits, took ~20x the tokens, and 30-67 s median against ~5 s. (`stage.ts:589-592,619-623`; `docs/reference/configuration.md:123-125,545-552`; CHANGELOG 0.9.23)
332
+ - **The judge response schema is strict.** The shape is `{scores:{need,preservation,quality}, reason}`, each score an integer 1-5, all required. A missing or out-of-range criterion is a parse failure. A reply the stage can't parse gets one corrective retry, and a missing or out-of-range criterion makes `parseJudgeResponse` return `undefined`, which is exactly what `callStage` retries; only extra keys pass without a retry. (`stage.ts:534-580`; `stage.ts:123-131`; CHANGELOG 0.9.25-alpha.2)
333
+ - **Rubric text depends on what drove the revision.** If any feedback line starts `[negative]`, the rubric says the revision cannot change the body so need not resolve the feedback, and judges only the changed fields. It justifies a change only if the field claims more than the body covers, or the note is stale (then a `when_to_use` must name the version or date). Otherwise it is the "maintenance" paragraph: only a missing or broken field needs a change, and rewording a sound field is churn. The switch reads feedback of any age (see Recent feedback is shown), so a proactive pick whose history holds an old or already-fixed negative is judged under the permissive "negative" paragraph, and unscoped lines carry a ref prefix so `[negative]` never matches there. *Why:* most negative feedback is a retrieval miss. (`stage.ts:463-468,480`; `reflect.ts:139-152`)
334
+ - **A judge on an agent engine gets a read-only verification paragraph.** It may read the asset, or one the changed region names, via `akm_show`. The purpose is to verify a fact the revision adds or alters. At most one or two reads, and never search. Each added statement must be found in the asset or feedback, and each source fact must be in the revision. *Why:* with the plain judge on qwen3.8-27b, the agent judge passed 38 of 40 good edits and 14 of 60 bad, against 34 and 12. A thinking engine is needed, since without thinking the model looped on tool calls. The plain judge's prompt is unchanged. (`stage.ts:448-456,507,669`; CHANGELOG 0.9.25-alpha.2)
335
+ - **The judge sees the patched content, not the persisted bytes.** It receives the source, the full candidate (the `applyReflectPatch` output), a "changed region" diff, and the feedback (truncated to 1,000 chars). `createProposal` then passes the `.md` payload through `ensureAkmMarkdownType`, which splices `type:` and `updated: <date>` into the frontmatter when missing (or adds a whole block), and the `staged` hash is computed on that post-stamp proposal, so the drain accepts bytes the judge never saw (#1147 names "`type` and provenance stamps"). Each document is bounded to 6,000 chars (2,960 head and 2,960 tail around a middle-omitted marker), narrower than the writer's 12,000 or more: a description grounded only in the omitted middle of an asset over 6,000 chars can only score "not supported" on QUALITY and is `quality_rejected` for 14 days; only an agent-engine judge can read the asset. (`stage.ts:281,424-446,471-510`; `reflect.ts:1025-1030,1044-1093`; `repository.ts:431-433`; `src/core/asset/akm-markdown.ts:39-77`; `prompts.ts:104`)
336
+ - **A judge pass stamps the proposal `staged`.** The gate decision is `outcome: "staged"`, `reason: "quality-judge"`, `gate: "quality-gate"`. It carries the judged content hash, per-criterion scores and the judge reason. The drain accepts it only while the content hash still matches. (`stage.ts:241-292`; `src/commands/proposal/drain.ts:597-603`)
337
+ - **`akm improve judge` runs the reflect judge on one stdin revision.** It reads `{source, candidate, feedback, ref}` and writes nothing. It uses the reflect gate's engine, for testing a judge engine. (`src/commands/improve/improve-cli.ts:255-301`)
338
+
339
+ ### After the judge
340
+
341
+ - **Nothing follows the judge (changed in this PR, decision 9).** A revision the judge passes is minted as a proposal at once: stamped `staged` when the judge ran, unstamped when it is off. Reflect no longer grades the old and the new text against the asset's own retrieval queries, so there is no `retrieval-ungraded` review reason, no `retrievalRegression` event metadata and no ledger row from such a check. The check used to refuse a revision whose mean grade dropped at all (3.00 to 2.60 was enough) with a 14-day `quality_rejected` window. *Why:* re-measured on 0.10 (akm-eval `evals/reflect` with retrieval queries and the judge on, `--repeat 3`), it refused 12% (35B, 11 of 90 public; 6 of 89 private) and 17% (27B Q4, 17 of 101 public; 11 of 96 private) of the revisions the judge had passed, mostly correct fixes, and lowered defects fixed from 84 to 77 (35B) and from 92 to 77 (27B) of 96. Reflect now edits frontmatter only, so an edit has little room to hurt retrieval. ([itlackey/akm#1171](https://github.com/itlackey/akm/issues/1171)) (`reflect.ts:1118-1148`)
342
+ - **Reflect has no accept-time continuity check of its own.** Accept-time freshness checks the target's `beforeHash`: a target changed after mint is auto-rejected `stale-target`. (`drain.ts:142-211`)
343
+
344
+ ### Validation
345
+
346
+ - **Pre-judge defect filter (deterministic, runs even with the judge off).** The first matching rule refuses the revision as `quality_rejected`. It makes no judge call and creates no proposal, and the event carries `reflectDefect`. Each rule counts only what the revision adds relative to the source, so text the asset already had is not held against it.
347
+ - `placeholder_added`
348
+ - `meta_commentary_added`
349
+ - `frontmatter_copied_into_body`
350
+
351
+ *Why:* on 396 labelled edits (83 good, 313 bad) the default lists hit 22 bad and 0 good ones, "so a hit is refused unjudged". (`reflect-noise.ts:39-134`; `reflect.ts:1086-1088`)
352
+ - **Default placeholder phrases.** `please confirm`, `please verify`, `to be confirmed`, `to be determined`, `to be verified`. `TODO`, `TBD` and `FIXME` are deliberately not on the list. *Why:* "an asset may carry those on purpose, and the owner does not want them refused". (`reflect-noise.ts:55-62`)
353
+ - **Default meta-commentary phrases.** 23 phrases: `feedback signal(s)`, `feedback indicate(s)`, `feedback suggest(s)`, `feedback ask(s)`, `feedback says`, `feedback report(s)`, `feedback request(s)`, `this revision`, `the source asset`, `the source note`, `the source memory`, `the original asset`, `the original note`, `the original memory`, `the original version of this`, `quality gate rejected`, `proposal rejected`. *Why:* an asset talking about its own edit is a defect no judge needs to weigh. (`reflect-noise.ts:64-88`)
354
+ - **Frontmatter-copied-into-body.**
355
+ - **Key lines.** A body line outside code starting `key:` for any of 20 keys (`sources`, `updated`, `inferenceProcessed`, `captureMode`, `beliefState`, `xrefs`, `contradictedBy`, `outcomeData`, `orderedActions`, `generated`, `verified`, `description`, `when_to_use`, `tags`, `searchHints`, `quality`, `salience`, `salienceInputs`, `lint_skip`, `type`) must not be gained.
356
+ - **Provenance values.** A `sources`, `xrefs` or `contradictedBy` value of at least 12 letters/digits newly appearing in the body also trips it.
357
+ - **Override.** `processes.reflect.defectFilter.{placeholders,metaCommentary,frontmatterKeys}` replaces a default list, and `[]` turns the rule off.
358
+
359
+ (`reflect-noise.ts:90-111,136-176`; `src/core/config/schema/improve-processes.ts:101-114`)
360
+ - **Generated content safety.** If the patch text contains the `[REDACTED]` marker, or equals a configured credential in the dispatch's sensitive values, it is rejected as `parse_error`. Nothing is persisted. *Why:* "refusing to persist already-redacted text" or an echoed credential (#962). (`reflect.ts:1290-1295`; `src/core/content-safety.ts:21-34`)
361
+ - **Output contract.** The reply must be exactly `{confidence, frontmatterPatch}` (plus `ref` when unscoped). `confidence` is in [0,1]. `frontmatterPatch` has exactly three keys, each a non-empty single-line string or `null`. A fallback "framed" format applies when the endpoint rejects JSON Schema: `AKM_REFLECT_CONFIDENCE`/`AKM_REFLECT_REF`/`AKM_REFLECT_FRONTMATTER_PATCH` lines. *Why:* all engine kinds are asked for the same reply. Framing is for endpoints that reject JSON Schema. (`reflect.ts:313-351,357,448-494,909-910`)
362
+ - **One repair turn, shared across passes.** An unparseable reply gets exactly one repair turn within the original deadline (`repairAttempts` budget is shared across refine passes); a second failure returns `parse_error`. *Why:* "within the original deadline, then fails". A stage retries only a reply it cannot read, not one that merely misses the schema. (`reflect.ts:578-602,983`; CHANGELOG 0.9.25-alpha.2)
363
+ - **`enableThinking: false` is forced on every reflect dispatch.** *Why:* code comment: "Visible chain-of-thought can exhaust the output before the envelope." Docs add: improve's reflect always requests `enableThinking: false` "for a machine-readable payload". (`reflect.ts:555-556`; `docs/reference/configuration.md:121-125`)
364
+ - **Runs under the model-work tool policy.** Credentials are checked, and a transport that can't confine the policy fails, before any work. An agent can edit only its scratch directory, so it returns the proposal as its reply. (`reflect.ts:844-857,933-940`)
365
+ - **Mint-time check is structural only.** Lesson, task and workflow proposals must parse as their type, else `invalid_canonical_structure`. Quality validators run at accept. *Why:* running the blocking durable-content validators at mint would throw instead of minting for a person to review. (`src/commands/proposal/repository.ts:439-463`; `src/commands/proposal/validators/proposal-validators.ts:149-172`)
366
+ - **Accept-time blocking validators.** These apply to reflect proposals.
367
+ - **Truncation marker.** A body still containing `... [truncated — focus on the visible portion]` is blocked.
368
+ - **Redaction marker.** Any `[REDACTED]` is blocked.
369
+
370
+ Both scan the whole `proposalContent`, including the untouched body: a source body that already carries the truncation marker or `[REDACTED]` makes every frontmatter-only reflect proposal for it unacceptable, and the remedy "reflect this ref again" cannot help.
371
+
372
+ *Why:* truncated or redacted content would silently replace a full asset with an incomplete one (data loss) (#952, #962). (`proposal-quality-validators.ts:313-345,407-418`)
373
+ - **Accept-time advisory validators (warnings do not block).** These are description quality, lesson content quality, source-not-superseded (consolidate), the reflect size guard and prompt scaffolding. The size guard compares bodies:
374
+ - Shrinking below max(50% of source, 150 bytes) flags `EXCESSIVE_SHRINKAGE`.
375
+ - Growing past min(max(250% of source, 2,500 bytes), 25,000 bytes) flags `EXCESSIVE_EXPANSION`.
376
+ - Sources under 200 bytes are skipped.
377
+ - A body that doesn't grow is never expansion.
378
+
379
+ *Why:* "a human accepting after reading the diff has no way to edit the proposal, so blocking on them left no remedy". The size guard is a leftover from body-rewriting reflect; a frontmatter-only patch can't trip it. (`proposal-quality-validators.ts:186-196,248-286,290-307,364-405`; CHANGELOG 0.9.25-alpha.3 "Removed")
380
+ - **Description and when_to_use shape.** Description is 20-400 chars. `when_to_use` is 15-400 chars. Rejected shapes include: leading digit or markdown marker, trailing `: ; ,`, trailing connector word, heading-fragment patterns, code-fragment starts, odd backtick count, the "Lesson distilled from" placeholder, and "When working with ...". Starting with "When" is only a warning. (`proposal-quality-validators.ts:58-135`; `src/core/authoring-rules.ts:32-37`)
381
+ - **A truncated description is repaired on accept.** The only repair applied is completing a truncated single-line `description`, plus CRLF normalisation. *Why:* "an earlier repair that deleted body lines gutted any asset documenting frontmatter". (`src/commands/proposal/validators/proposals.ts:28-52`)
382
+
383
+ ### Proposal outcome
384
+
385
+ - **Mint with source `reflect`, a `sourceRun` of `reflect-<ms>`, and the model's self-reported confidence.** The proposal is stored with a FileChange envelope, before-hashes of the target, and eligibility source. Self-reported confidence is stored for reviewers and the triage judge. A `proposed` ledger row (7-day soft window) is written on mint. The mint rewrites the `.md` payload with `ensureAkmMarkdownType` for every source, not only reflect: a missing `type:` and a missing `updated:` are each added as one line before the closing `---`, or a new block is added when there is no frontmatter. (`reflect.ts:1124-1148`; `repository.ts:350-510,431-433`; `reflect.ts:324-330`; `src/core/asset/akm-markdown.ts:39-77`)
386
+ - **Outcome by status.**
387
+
388
+ | Case | Result |
389
+ |---|---|
390
+ | Judge passed | Pending, stamped `staged` (gate `quality-gate`, reason `quality-judge`, with content hash, scores and reason). The triage drain accepts it while the content hash still matches. |
391
+ | Judge failed (timeout/error/unparseable) | Pending, `deferred`, gate `quality-gate`, reason `judge-error`. Left for a person. |
392
+ | Judge scored below pass with a mean of 3.5 or more (changed in this PR, decision 4) | Pending, `deferred`, gate `quality-gate`, reason `judge-uncertain`, with scores and reason. Left for a person. |
393
+ | No judge configured | Pending, `deferred`, gate `reflect`, reason `no-judge-configured`. |
394
+ | Judge disabled (`qualityGate.enabled: false`) | Pending, unstamped; the drain decides. |
395
+ | Judge scored below pass (mean under 3.5), defect hit | No proposal, `quality_rejected`, 14-day hard ledger window. |
396
+ | Noop or cosmetic | No proposal, `no_change`, 7-day soft window (`unchanged`). |
397
+ | Unsupported type or file outside layout | Skipped, `unsupported_type`, 7-day soft window (`unchanged`). |
398
+
399
+ Lines for these: `reflect.ts:1118-1148,1063-1085`; `loop-stages.ts:241-268`. With the gate off, `staged` is never stamped (`reflect.ts:1137-1147`). *Why:* the review/queue semantics are the gate decision on the proposal.
400
+ - **Nothing prevents a second pending reflect proposal for the same ref.** `createProposal` leaves "may this ref be proposed again" to the stage's candidate selection, which reads only the ledger; the `proposed` row is a 7-day soft window that newer negative feedback lifts, and the proactive lane re-admits an asset after `dueDays` (30). So an unreviewed asset is re-reflected at about day 30 and day 60 before the first proposal expires at day 90 (Reflexion context shows rejected proposals only), and an explicit `--scope <ref>` always mints another. If one sibling is accepted, the others are auto-rejected `stale-target` and the ledger records `failed`. Only retire proposals have a one-pending-per-asset rule. *Why:* not stated. (`repository.ts:344-349`, `preparation.ts:858-863`, `proactive-maintenance.ts:67-86`, `stage.ts:218-229`, `pair-pass.ts:948-953`)
401
+ - **Auto-accept is the triage drain, with deterministic gates.** The `default` strategy runs triage `applyMode: "promote"`, `maxAcceptsPerRun: 200`, judgment off. Accepts are staged proposals (still matching hash), capped by `maxAccepts`. Empty diffs are rejected. Any `deferred` decision from another gate is left for a person. *Why:* only the judged drain caused harm (#1132; #1147 later found the deterministic drain accepting harmful proactive edits, hence the proactive rule below); a lower cap of 25 let the review queue grow nightly (200 default since 0.10.26101002). (`drain.ts:5-28,551-590`; `src/assets/improve-strategies/default.json:15`; CHANGELOG 0.10.26101001/2)
402
+ - **Proactive-lane proposals are never auto-accepted.** The drain defers any proposal with `eligibilitySource === "proactive"` as `proactive-needs-review`. This holds through a staged accept and the judgment tier. Feedback-driven (`signal-delta`, `scope`) proposals are unchanged. *Why:* with the lane on, reflect edits on assets no one had asked about were accepted unattended after the judge passed them (`when_to_use`, `type` and provenance stamps, a title heading added) (#1147). (`drain.ts:13-16,593-596`; CHANGELOG 0.10.26101001 "A proposal from the proactive lane waits for a person")
403
+ - **The judgment tier can't accept reflect revisions.** It may only accept a consolidate promotion; anything else it would accept is left for a person as `judgment-not-promotion`. No built-in strategy turns judgment on. *Why:* judging over every kind solved ~0.5 of eval items and harmed 11-14, accepting unsafe retirements and applying exact fixes unreviewed (#1132). (`drain.ts:20-24,435-442`; `docs/reference/configuration.md:495-509`)
404
+ - **Autonomy gate: only a promote with judgment on is downgraded.** Triage in promote mode with judgment enabled is downgraded to `queue` unless `experimental.improveAutonomy` is set. A deterministic-only promote runs without the opt-in. *Why:* #1143. (`src/commands/improve/autonomy-gate.ts:5-18,112-122`)
405
+ - **A stale target is auto-rejected, not overwritten.** If the target changed (or was created) after mint, accept fails and the drain auto-rejects with `stale-target`. The ledger records `failed`, keeping the ref re-proposable. A retire proposal whose target or successor is gone, or whose bodies changed since judging, is classified as stale-target too (see Consolidate: A retire proposal expires, and a stale one is rejected once). (`drain.ts:142-211`; `repository.ts:489-491`)
406
+ - **Human review rejection feeds back as Reflexion context.** A person's `akm proposal reject` writes a `rejected` ledger row (14-day hard window for reflect) and the rejection reason appears as "Previously Rejected Proposals" in later prompts. The 14-day window is partly redundant with that Reflexion context and also blocks legitimately different new feedback (see Soft versus hard ledger windows). (`improve-ledger-repository.ts:74-77,149-152`; `stage.ts:209-236`)
407
+ - **Failure routing and classification.** `unsupported_type` and `no_change` are deterministic skips. They are tallied as `reflect-skipped`, recorded in the ledger as `unchanged` (7-day soft window), and do not feed "Avoid These Patterns". Real failures are `reflect-failed`, write no ledger row, and their error message (except a budget abort) joins the rolling window of 3 shown to later reflect prompts. A `quality_rejected` records itself, and its judge text is not fed back. `content_policy_reject` maps to `reflect-guard-rejected` and was the old size-rail bucket; nothing in the 0.10 reflect path emits it any more. Only a guard rejection records `failed`, so a transient failure never consumes the signal that planned the ref (fixed in this PR, #1). *Why:* "A refused type or an unchanged asset is a deterministic skip, not an LLM fault"; "the judge's text is no lesson for the next prompt". (`loop-stages.ts:240-266`; `src/integrations/agent/spawn.ts:30-60`)
408
+ - **Plasticity counter.** Each `no_change` result increments a per-asset consecutive-no-op count; a successful proposal resets it. Three consecutive no-ops halve the asset's selection rank. (`loop-stages.ts:149-160,277-278`; `salience.ts:129-134`)
409
+ - **Event trail.** Every invocation closes with one `reflect_completed` event, with `reflect_invoked` once credentials validate. Failures carry the reason and subreason (`unsupported_type`, `no_change`, `parse_error`, `agent_crash`, `spawn_failed`; also `reflect_skipped_noop`/`_cosmetic`, `enoent` and `file_outside_layout`). A `quality_rejected` `reflect_completed` event instead carries `qualityRejected: true` plus the score, criteria or defect metadata, and no `ok`, `reason` or `subreason`. The loop also emits `improve_reflect_outcome`. A judge-uncertain proposal's event carries `qualityReason` and `qualityScore` (changed in this PR, decision 4). (`reflect.ts:12-14,638-668,1149-1162,1257-1282`; `loop-stages.ts:270-279`)
410
+
411
+ ### Code and doc mismatches
412
+ - `default.json` and `docs/guides/improve-the-library.md:106` describe reflect as "rewrites only from negative feedback". That is true of the signal-delta lane, but the proactive lane also plans reflect on assets with no feedback (then the "maintenance" judge paragraph applies). (`default.json:2`)
413
+ - `salience.ts:129-134` documents the no-op dampener as "Consolidation selection only", but `preparation.ts:956-969` applies it when ranking the reflect/distill loop.
414
+ - The `content_policy_reject` / `reflect-guard-rejected` classification still exists, but the reflect size rail it was named for was removed in 0.9.25-alpha.3.
415
+ - Reflect's judge labels a mean in [2.5, 4) as "review" (`runQualityJudge`), but reflect sends only a mean of 3.5 or more to a person and refuses 2.5 to 3.5; the cut is `REFLECT_REVIEW_MIN_SCORE`, not the judge's label (changed in this PR, decision 4). (`stage.ts:643`, `reflect.ts:1018,1104-1116`)
416
+ - The code comment at `reflect.ts:239-241` and the judge intro (`stage.ts:464`) say the body is kept byte for byte / unchanged apart from an added title heading; neither mentions that every leading blank line of the body is stripped when the heading is prepended (`reflect.ts:258-259`).
417
+
418
+ ## Distill
419
+
420
+ (Read from this branch. Distill turns a memory plus its feedback into a lesson proposal, or a knowledge proposal for a reinforced memory. It never writes an asset directly.)
421
+
422
+ - **Only memories are distilled; lessons, env and secrets are refused** — Default `allowedTypes` for distill is `["memory"]`. `lesson`, `env` and `secret` inputs are refused with `recursive_lesson_input` or `refused_secret_input` and make no LLM call. `.derived` memories and memories with `quality: proposed` are not candidates. *Why:* a lesson is the distilled form, and distilling one would mint `lessons/…-lesson-lesson`; env/secret bytes must never reach the model. (`src/commands/improve/distill.ts:84-92,484-493`; `src/commands/improve/improve-strategies.ts:36-40`; `src/commands/improve/eligibility.ts:343-359`)
423
+
424
+ - **Distill needs a feedback signal in the last 30 days (otherwise it waits)** — In the improve loop, a memory enters distill only if it has any signal in the 30-day window (`FEEDBACK_SIGNAL_WINDOW_DAYS = 30`, a signal or a note). Newer feedback than the last distill attempt also has to clear the ledger window. A memory with no signal is skipped with "memory requires recent feedback signal" (`memory_distill_requires_feedback`). The proactive-maintenance lane picks stable, feedback-less assets, so its picks are never distilled. Refs that are "cooled" for distill get `distill_no_new_signal`. An explicit `--scope <ref>` bypasses all of these gates. *Why:* not stated in code/docs beyond the gate's description ("no fresh negative feedback (or, for distill, any signal) since the last attempt"). (`src/commands/improve/loop-stages.ts:302-313`; `src/commands/improve/preparation.ts:678,858-893,994-997,1038`)
425
+
426
+ - **"No new signal since last proposal"** — A ref whose latest feedback is not newer than its last distill ledger attempt, or that sits in a live ledger window, is dropped from the plan and reported as `distill-skipped` with the reason "no new signal since last proposal". *Why:* not stated beyond the gate description; the health report hides this reason from the skip chart because it dominated it. (`src/commands/improve/preparation.ts:858-865,998-1009`; `src/commands/health/report-view-model.ts:30`)
427
+
428
+ - **Positive feedback without a reason is not enough (`distill_positive_without_reason`)** — If every in-window signal on the memory is `positive` with no `reason` and no `note`, distill is skipped ("only positive feedback, without a reason"). The ledger records it as `unchanged`, so the memory waits for newer feedback. A reason, a note or any negative signal in the window lets it through. *Why:* a bare `--positive` gives the writer nothing to distil, so it restated the memory; 10 of the 11 lessons made from such memories were rejected (2026-10-05 review). (`src/commands/improve/loop-stages.ts:286,320-326`; `src/commands/improve/preparation.ts:728-747`; CHANGELOG "Distill skips a memory whose only recent feedback is positive")
429
+
430
+ - **Flagged-wrong-and-unedited memories are skipped (`distill_flagged_wrong`)** — A memory with a negative feedback in the 30-day window is skipped while its body still matches the hash the feedback recorded. Feedback with no recorded hash is compared against the file mtime instead. The ledger records `unchanged`. *Why:* a memory marked wrong and not edited is no source for a lesson. (`src/commands/improve/loop-stages.ts:285,314-319`; `src/commands/improve/preparation.ts:686-711`)
431
+
432
+ - **Deprecated or superseded memories are skipped (`distill_deprecated_or_superseded`)** — A memory with `beliefState: deprecated` or `superseded` is skipped; `contradicted` is still distilled. *Why:* the 2026-10-08 distill-tune run found 26 of about 900 memories carry the state and none gave a lesson. (`src/commands/improve/loop-stages.ts:287,327-332`; `src/commands/improve/preparation.ts:713-726`)
433
+
434
+ - **`requirePlannedRefs`** — With `processes.distill.requirePlannedRefs: true`, distill-only refs are skipped when no ref was planned for reflect. The shipped `default` strategy sets it to false. *Why:* it avoids hundreds of `distill-skipped` events on quiet passes. (`src/commands/improve/loop-stages.ts:98,113,299-301`; `src/assets/improve-strategies/default.json:9`; CHANGELOG 0.8-era "distill.requirePlannedRefs")
435
+
436
+ - **Cooldowns after a distill attempt** — The improve ledger blocks a retry after `quality_rejected` for 30 days (distill's `LEDGER_REJECTION_WINDOW_DAYS`; reflect is 14, others 7), and after `unchanged`, `review_needed` or `proposed` for 7 days. New feedback lifts the soft 7-day windows but never the hard ones (`rejected`, `quality_rejected`, `expired`). *Why:* the rejection windows are "the constants the rejection backoff always used"; the ledger as a whole replaced the per-stage cooldown constants that "none of which agreed with each other" (`improve-ledger-repository.ts:1-26`). (`src/storage/repositories/improve-ledger-repository.ts:67-82,131-163,191-196`)
437
+
438
+ - **Proactive maintenance does not feed distill (apart from its clock)** — The lane's picks come from the feedback-less pool, so they are absent from the signal-bearing set, and the loop skips distill for every proactive memory with `memory_distill_requires_feedback`; non-memory picks fail distill's type filter. The only link is that a distill attempt on a ref resets the lane's due clock (either a reflect or a distill attempt does). *Why:* "the cap against a proposal flood". (`src/commands/improve/loop-stages.ts:302-310`, `src/commands/improve/preparation.ts:918-946,1080-1147`, `src/commands/improve/proactive-maintenance.ts:10-14,67-86`)
439
+
440
+ - **Feedback window given to the writer** — The prompt gets the last 20 feedback events. Positive and negative signals are rendered as "What worked" / "What failed". The asset body is cut to 3000 chars after its frontmatter is stripped. *Why:* source frontmatter is not evidence; fed verbatim, models copied it into the body. (`src/commands/improve/distill.ts:325-330,344-363,550`)
441
+
442
+ - **CLS related-assets context** — By default (`processes.distill.cls.enabled !== false`) the writer is shown the nearest 3 (`adjacentCount`, default `DEFAULT_CLS_ADJACENT_COUNT`) lessons, knowledge notes and skills. Search runs once per type, and each entry is capped at 600 chars. The prompt says "answer NONE if one already states the rule; do not contradict or overwrite". *Why:* neither the writer nor the judge could see a knowledge note or skill that already states the rule; "memories outnumber the rest and would fill an untyped list". (`src/commands/improve/distill-guards.ts:10-29`; `src/commands/improve/distill.ts:1318-1329,1355-1382`; CHANGELOG "Distill writes a lesson only when its memory holds one…")
443
+
444
+ - **Reflexion context from rejected proposals** — Up to 3 of the newest reviewer-rejected proposals for the ref are shown ("must differ meaningfully"). Procedural rejections (expiry, stale target, missing asset) are excluded. The lookup is by the *input* memory ref (exact match) while distill files proposals at the output lesson ref, so it shows rejections of reflect rewrites and feedback fixes on the memory, never the memory's own rejected lessons. *Why:* "don't repeat this". (`src/commands/improve/stage.ts:209-236`; `src/commands/improve/distill.ts:365-376,1317`; `proposals-repository.ts:512-514`)
445
+
446
+ - **Lesson vs NONE** — The writer replies `decision: lesson|none` (with a `reason` written first) or the bare word `NONE`. A lesson requires a cause and what to do about it, or a rule with its reason, and the writer may add nothing the memory and its feedback do not state. NONE applies to "what was done/shipped/decided", a pending plan, current setup, a procedure's steps, or feedback that says only out-of-date. NONE first tries the lesson-update path when a lesson is nearby (see Lesson update), which can cost a writer and a judge call and mint a `queued` update proposal. Only if that yields nothing is it a `skipped` outcome with `skipReason: nothing_reusable`: no proposal, ledger `unchanged`. *Why:* 18 of 19 queued lessons on 2026-10-05 were records of what was done. (`src/assets/prompts/distill-lesson-system.md`; `src/commands/improve/distill.ts:159-195,259-270,597-611`; CHANGELOG "Distill writes a lesson only when its memory holds one, says only what the memory says…")
447
+
448
+ - **Lesson vs knowledge kind (`proposalKind`)** — The loop passes `"auto"` for memories. A reinforced memory can graduate to a knowledge proposal with no generation call (see the promotion rule below). Otherwise it falls through to the lesson path. A direct request for `knowledge` uses the knowledge system prompt and schema, but the promotion assessment runs first for it too, so a promotable memory takes the no-generation path. *Why:* not stated in code/docs. (`src/commands/improve/loop-stages.ts:338`; `src/commands/improve/distill.ts:99,467-469,527-536`)
449
+
450
+ - **Memory → knowledge promotion score** — Promotion needs a weighted score of at least 3.8. Per positive feedback event (max 4) +0.8; two or more positives +0.65; no positive −0.9; exactly one positive −0.7; each negative −2.0; `quality: curated` +0.55; `confidence` ≥0.95 / ≥0.85 / ≥0.75 scales a 0.7 weight by 1 / 0.7 / 0.35; `source` +0.4; `observed_at` +0.4; description +0.2; tags +0.15; body of 8 or more words +0.15; tentative words (maybe, might, tbd, todo, investigate…) −1.1. Hard blockers are `.derived`, an empty body, conflict/contradiction/superseded/obsolete/retracted markers, `quality: proposed`, `subjective: true`, an `expires` field, and conflict markers in feedback. Promotion counts positive events, bare or not, over all time (`distill.ts:1288-1297`), but the loop refuses to invoke distill when every in-window signal is a bare positive (`loop-stages.ts:323-326`), so a memory with four bare positives (score 3.85, over the 3.8 bar) never promotes through the loop; the guard's rationale ("the writer has nothing to distil") does not apply to promotion, which makes no writer call. *Why:* "weights were chosen by a grid search over a labelled corpus; they are a plain constant now". (`src/commands/improve/distill-promotion-policy.ts:125-155,226-300,308-324`)
451
+
452
+ - **Promotion destination merge** — Promotion writes to `knowledge/<leaf>` (the memory's last name segment). Only a file inside the same stash counts as the existing destination (#1000). With a runner, the model picks `ADD`, `UPDATE` or `NOOP` over the existing text (3000 chars each); `NOOP` returns `skipped` with `conflict_noop`. With no runner, the existing text is appended for the reviewer. Promotions go through the same judge, with the plain lesson rubric and no `extending` text, whose GROUNDING requires every statement to be in the source memory or its feedback; the existing knowledge doc's lines are in neither, so an ADD/UPDATE merge is expected to score 2 or less and be `quality_rejected` for 30 days, and the merge path yields a proposal only with the gate off. *Why:* the promotion is filed in `run.stash`, so only that bundle's doc is its destination. (`src/commands/improve/distill.ts:731-737,1086-1185`; `stage.ts:382`; `src/commands/improve/distill-promotion-policy.ts:102-111`; #1000, D-1/#369)
453
+
454
+ - **Two memory to knowledge promotion paths, deduplicated one way** — Distill mints `knowledge/<leaf>` from the memory's last name segment; consolidate mints `knowledge/<slug>` from the model-supplied `knowledgeRef`. Consolidate skips when *any* pending proposal has the same concept, but distill checks only whether a file exists and never looks at pending proposals, and the two use different ledger sources, so neither cadence blocks the other. Both can be pending for one memory, and the drain can accept consolidate's while distill's waits for a person. *Why:* not stated. (`distill-promotion-policy.ts:102-111`, `consolidate.ts:1030-1050`, `distill.ts:1096-1102`)
455
+ - **Output ref and `lesson_exists` (no overwrite)** — The lesson ref is `lessons/[<first scope segment>/]<type>-<name>-lesson`, normalised to `[a-z0-9-]`. If a file already exists at that ref in the target stash, distill returns `skipped` with `lesson_exists` before any model call, and the ledger records `unchanged`. A same-named lesson in another bundle does not count. A memory's own prior lesson can therefore never be extended: the check runs before any model call, ahead of both lesson-update entry points, and only a *different* lesson named by the writer or the judge is extended; a memory edited with new facts after its lesson was minted cycles `lesson_exists`, `unchanged`, `lesson_exists`. *Why:* accepting a same-ref proposal replaces the lesson; all 5 recorded overwrites were rejected (2026-10-05 review). "To change a lesson, edit it." (`src/commands/improve/distill.ts:138-151,538-548,638-648`; CHANGELOG "Distill no longer regenerates a lesson that already exists")
456
+
457
+ - **Structured output and the strict schema** — The lesson schema is `{reason, decision, description, when_to_use, body, tags}` and the knowledge schema is `{description, body, tags, sources}`. Every property is required and "none" is an empty array. A reply missing a required field falls back to being read as markdown. *Why:* a strict provider (OpenAI) rejects an object whose `required` omits properties. (#1046) (`src/commands/improve/distill.ts:153-257`)
458
+
459
+ - **Deterministic lesson repair before judging** — Zero LLM cost. A missing `description` or `when_to_use` is filled from body lines that pass the validators (never a placeholder). A conditional description ("When/If…") is swapped with a declarative `when_to_use`. A description cut mid-sentence is completed (#556). *Why:* small models write a good body with no header; placeholder fills "produced hundreds of broken proposals". (`src/commands/improve/distill/content-repair.ts:22-114`; #556)
460
+
461
+ - **Structural validation is a hard reject** — A lesson without non-empty `description` and `when_to_use`, or a knowledge note with an empty body, an invalid description or double frontmatter, throws a `UsageError` and logs `validation_failed`. Descriptions must pass `isValidDescription` (min and max length, no leading digit or marker, no trailing `:;,` or truncation word, no code fragment, no heading fragment). *Why:* required structure missing means there is no asset to write. (`src/commands/improve/distill.ts:272-303,668-687`; `src/commands/proposal/validators/proposal-quality-validators.ts:63-112`)
462
+
463
+ - **Heuristic quality findings go to a human** — If lesson quality findings remain (invalid description or `when_to_use`, identical description and `when_to_use`, pseudo-frontmatter in the body), the content is minted as a `review_needed` proposal at score 2.0. The gate decision is `deferred`/`quality-gate`, reason `quality-review`. *Why:* "heuristic quality findings go to a human, not the bin". (`src/commands/improve/distill.ts:688-698,991-1075`; `src/commands/proposal/validators/proposal-quality-validators.ts:228-267`)
464
+
465
+ - **Quality gate (judge) is on by default** — `processes.distill.qualityGate.enabled` defaults to true. The gate can name its own judge engine, model, timeout or llm (#1011). A gate that names judge settings resolving to no engine throws `ConfigError` before generation (the loop reports `mode:"error"` with no ledger row), and a named judge never falls back to another judge. A gate that names nothing falls back to `defaults.llmEngine`. A judge absent at judge time (the promotion path) returns `pass:false, score:-1, reviewNeeded:true` (changed in this PR, decision 4), so `rejectDistilled` records `review_needed` and queues the lesson as a pending proposal (gate `quality-gate`, reason `quality-review`) for a person, as reflect does with `no-judge-configured`; it no longer records a 30-day `quality_rejected`. Plan resolution never pre-checks a `qualityGate` engine. A configuration gap is not a verdict on the memory. *Why:* #1011. (`src/commands/improve/distill.ts:702-704,747-750,1008-1043`; `src/commands/improve/stage.ts:311-345,593-613`; `reflect.ts:1137-1147,1236-1242`; `improve-strategies.ts:434-454`)
466
+
467
+ - **Judge rubric (reusable / nonRedundancy / grounding)** — Three 1-5 integer scores. REUSABLE: a rule with its reason, not a record of what was done. NON-REDUNDANCY: compared only to the related assets listed (it scores 5 if none are listed). GROUNDING: every cause, step, number and limit appears in the source or its feedback. The judge sees the same 3000-char source body the writer saw, the 1500-char feedback lines, and the 2000-char proposed lesson. `temperature: 0`. Thinking is off unless the judge's own engine enables it. *Why:* the old "information not already present in the source" criterion let invented claims score as novel. (`src/commands/improve/stage.ts:364-422,593-645`)
468
+
469
+ - **Judge routing: any criterion at 2 or below rejects** — `grounding` is scored but excluded from the mean. A pass needs every criterion in the mean at 4 or above. Otherwise a mean of at least 2.5 is `review_needed`, and a lower mean is rejected. Any lesson criterion at 2 or below (`LESSON_REJECT_MAX_SCORE = 2`, grounding included) is `quality_rejected` whatever the mean. A judge timeout, parse failure or missing judge engine routes to review (`score -1`; the missing engine changed in this PR, decision 4). Grounding is excluded from both the mean and the `lowest >= 4` pass test, so reusable 5, nonRedundancy 5 and grounding 3 ("one stretches what the source says") passes outright; only grounding 2 or less vetoes. The only recorded reason for excluding it (CHANGELOG 0.9.19: the judge was not shown the feedback) no longer holds, because the judge has been shown the feedback since 0.9.27-alpha.2 (`stage.ts:390-397`); adding grounding to the pass bar was measured and leaves the outcome unchanged, so it stays out (Policy decision 6). *Why:* "a mean hid a decisive score (4 and 1 average 2.5)"; 17 of 19 lessons that were not rejected were bad on 2026-10-05. (`src/commands/improve/stage.ts:512-523,552-557,583-643`)
470
+
471
+ - **A passing lesson goes to a person, never the drain (`distill-review`)** — A judged pass is minted `deferred` with gate `quality-gate`, reason `distill-review`, and carries the criterion scores and the judge reason. Judge confidence is `score/5`. With the gate off nothing is judged and the proposal is minted unstamped for the drain to decide. *Why:* staged precision was 2 of 12 on 2026-10-05; no judge score separated the two good lessons from the ten bad ones. (`src/commands/improve/distill.ts:769-796`; `src/commands/improve/stage.ts:240-260`)
472
+
473
+ - **Provenance and salience** — The minted lesson's frontmatter gets `xrefs` including the input ref (with `sources` removed). Input salience is stamped on the memory (a file write plus `asset_salience`), and output salience is stored at mint. Salience scoring never blocks distillation. The input stamp runs before the transport-failure check, so it rewrites the memory's frontmatter (and mtime) even on `llm_failed`, and it overwrites `asset_salience` with `retrievalFreq: 0`, zeroing the retrieval and outcome terms until the next live rescoring; this is a second `asset_salience` writer next to the preparation stage. *Why:* "lessons are refused as inputs, so this is their only chance" to carry a real encoding salience. (`src/commands/improve/distill.ts:576,756-767,1222-1285`; `src/storage/repositories/salience-repository.ts:106-108`)
474
+
475
+ - **Lesson update — when it applies (#1090)** — If the judge scores non-redundancy at 2 or below (the draft repeats an existing lesson), or the writer answers `NONE` with a lesson nearby, distill tries to extend the existing lesson instead of discarding the draft. It needs the quality gate, a runner and a non-empty memory. *Why:* a repeated lesson was being thrown away although the memory might add facts. (`src/commands/improve/distill.ts:739-751,823-842`; CHANGELOG "Distill extends an existing lesson that a memory repeats (#1090)")
476
+
477
+ - **Lesson update — which lesson is a candidate (#1139/#1145)** — Candidates are the lessons among the top-3 related assets across lesson, knowledge and skill (so zero to three lessons, not "the 3 related lessons"), inside the stash. A candidate is a lesson whose ref or name appears as a substring in the writer's NONE reason or the judge's finding (a short name such as `lessons/ci` matches any finding containing "ci", for example in "specific"). If none is named and exactly one related lesson exists, that one is the candidate. If several lessons are nearby and none is named, no call is made. *Why:* "costs a call only for a lesson that is named" instead of trying every lesson among the nearest three. (`src/commands/improve/distill.ts:843-858`; CHANGELOG "Distill's lesson update (#1139)…")
478
+
479
+ - **Lesson update — writer contract (`new_facts`)** — The writer returns the existing lesson's whole body with lines added. It lists `new_facts` before the body: each cause, step, number, limit or consequence the memory states and the lesson lacks (retelling the memory's own incident is not a new fact). It answers `none` if the memory contradicts the lesson or adds nothing. A reply with no listed lesson ref, an empty `new_facts` or an empty body is discarded. *Why:* the update writer lists new facts first, and an update that lists none is not proposed. (`src/commands/improve/distill/lesson-update.ts:21-94`; CHANGELOG 0.10.26101001 (#1139)/(#1090))
480
+
481
+ - **Lesson update — existing lines kept verbatim** — Lines are compared lowercase and whitespace-collapsed, as sets: a dropped or reworded line means nothing is proposed, and no added line means nothing is proposed, but reordering lines or collapsing a duplicated line is not detected although the prompt forbids reordering. *Why:* a lesson update may only extend it, never rewrite it. (`src/commands/improve/distill/lesson-update.ts:96-120`; `src/commands/improve/distill.ts:877-882`)
482
+
483
+ - **Lesson update — the judge sees the merged lesson** — The judge reads the extended lesson (existing frontmatter plus the new body) as one lesson, against the memory as source, with the existing body passed as `extending`. NON-REDUNDANCY is then scored only on the added lines against the existing lesson, ignoring the memory. GROUNDING accepts a line stated by the memory, its feedback or the existing lesson. It needs the same pass bar (every criterion at 4 or above). *Why:* judged as added lines alone, a single new fact failed `reusable` and no update passed (0 of 8 on akm-eval `lesson-update`, on two models). (`src/commands/improve/distill.ts:884-897`; `src/commands/improve/stage.ts:377-382`; CHANGELOG #1139)
484
+
485
+ - **Lesson update — staged for review, with a stale-lesson check** — A passing update is minted on the lesson's own ref as `deferred`/`quality-gate`, reason `distill-update`. The payload is the merged body with `xrefs` including the memory, and the proposal's before-hash is taken at mint. If the lesson file changed since it was read, nothing is proposed. The outcome is `queued` with `updatesExisting: true`. If the update fails anywhere, the original outcome (reject, or `nothing_reusable`) stands. *Why:* "left for a person: the triage drain does not accept it". (`src/commands/improve/distill.ts:899-953`; CHANGELOG #1090)
486
+
487
+ - **Quality rejection record (`rejectedContent`)** — A `quality_rejected` outcome records a ledger row under the input key and a `distill_invoked` event, with no proposal. The event and result keep the rejected text cut to 2000 chars. *Why:* so a rejection can be audited; it stays local in `state.db`. (`src/commands/improve/distill.ts:980-1075`; CHANGELOG 0.9.28-alpha.3)
488
+
489
+ - **Failure outcomes** — An LLM transport failure or an empty reply gives `llm_failed` and does not count as an attempt, so the ref stays eligible. A config-disabled run (`processes.distill.enabled` not true) emits nothing. *Why:* a transport failure is not a verdict on the memory. (`src/commands/improve/distill.ts:470-480,577-595`; `src/commands/improve/loop-stages.ts:349-352`)
490
+
491
+ ## Extract
492
+
493
+ (Standalone `akm proposal extract`; in `akm improve`, the `extract` stage is off in the shipped `default` strategy. `improve-session.ts` is only the improve-run signal lifecycle (SIGTERM/SIGINT/SIGHUP persist a terminated-run row, with a 2000 ms watchdog) and is not part of extraction.)
494
+
495
+ - **Which harnesses and sessions are read** — Session logs come from the registered session-log harnesses (claude, codex, opencode). `--type <harness>` or `--auto` (every available harness) selects them. `--session-id` processes exactly one session, `--location` overrides discovery, and `--since` takes an ISO time or `<n>m|h|d`. *Why:* extract replaced the legacy session-checkpoint hook. (`src/commands/improve/extract-cli.ts:29-80,105-166`; `src/commands/improve/extract.ts:299-313,1024-1055`; `docs/architecture/improvement.md` "Session extraction")
496
+
497
+ - **Discovery window** — With no explicit window, discovery goes back to the last extract run for that harness (so a host that was off still finds sessions that ended meanwhile), but never less than 48h (`DEFAULT_SINCE_FLOOR_MS`). `--since` or `processes.extract.defaultSince` override this. A `--since` run lifts the per-run session cap. *Why:* the content-hash ledger makes the overlap free. (`src/commands/improve/extract.ts:89-118,1006-1011`)
498
+
499
+ - **Dedupe: unchanged sessions are skipped with zero LLM calls** — A session whose raw event stream hash (role plus text, excluding titles, timestamps and inline refs) equals the stored `content_hash` is skipped as `already_extracted`. Only `--force` overrides it, even for `--session-id`. A session whose stored hash is null is retried. *Why:* "the skip authority for already extracted". (`src/commands/improve/extract.ts:363-370,434-444`; `src/storage/repositories/extract-sessions-repository.ts:210-217`)
500
+
501
+ - **Hash is not recorded when the failure is transient** — `llm_unavailable`, `triaged_out` and `malformed_model_output` store a null hash so the session is retried later. `already_extracted` and `locked_concurrent` are not rewritten. `read_failed` and `malformed_model_output` are recorded with outcome `failed` (`read_failed` also ends with a null hash); an `exception` outcome is accounted but never persisted (`account`, not `accountAndRecord`), so `akm health` never sees those failures. *Why:* "one that failed for a transient reason keeps a null hash so it is retried". (`src/commands/improve/extract.ts:867-879,888-939`)
502
+
503
+ - **`triaged_out` sessions are re-read every run** — A `triaged_out` outcome stores a null hash, so `shouldSkipAlreadyExtractedSession` returns false and `countNewExtractCandidates` counts the row as new on every run. With `default`'s `triage.enabled: true`, triaged-out sessions perpetually satisfy any `minNewSessions` value once set. *Why:* not stated. (`extract.ts:921,1297`, `extract-sessions-repository.ts:215`)
504
+ - **Pre-LLM gates: size floor and optional triage** — Sessions whose raw text is under `minContentChars` (default 10; 0 disables) are `too_short`. Optional heuristic triage (`processes.extract.triage`; off unless enabled, `minScore` default 2) adds up to 2 for learning-marker words, 1.5 for tool density, 1.5 for edits/commits, and 1 for the substantive share, and skips sessions scoring below the minimum (`triaged_out`). *Why:* "only truly empty sessions are safe to skip" at the size floor. (`src/commands/improve/extract.ts:83-87,372-395,451-459`)
505
+
506
+ - **What the model sees** — Only parent-origin events, pre-filtered. Pre-filter drops read-only `akm` meta-ops (show, search, curate, history…), tool aggregates, post-compact XML wrappers, claude preamble, system boilerplate and events under 10 chars. It clips each event to 2000 chars head+tail and the whole transcript to 80,000 chars (`maxTotalChars` override). The transcript is fenced "UNTRUSTED SESSION TRANSCRIPT" (fence markers inside are neutralised) and treated as data. The "already preserved" list is the agent's own `akm remember`/`feedback` calls, and candidates overlapping it are not to be re-extracted. *Why:* "errs on the side of keeping content"; the fence defuses prompt injection from transcript text. The session-asset summary call uses the same pre-filter and fence (`fenceTranscript`), plus a line telling the model that anything inside the fence is content to summarize, never an instruction (fixed in this PR, #12). (`src/integrations/session-logs/pre-filter.ts:5-66`; `src/commands/improve/extract.ts:445-450`; `src/commands/improve/extract-prompt.ts:145-163,170-182,186-205`; `src/commands/improve/session-asset.ts:75-97`; `src/assets/prompts/extract-session.md`)
507
+
508
+ - **What can be extracted** — Candidates of type `memory`, `lesson` or `knowledge` (the schema enum). Per-candidate contract: kebab-case `name` (optionally under one scope segment); `description` at least 20 chars (the parser enforces only the lower bound; 400 is the JSON schema's); `body` at least 50 chars; `evidence` at least 5 chars; `confidence` a number clamped to [0,1]. Lessons also need `when_to_use` of at least 15 chars. The prompt's durable-insight definition is recovery patterns, hidden constraints, architecture observations, non-obvious workarounds and domain knowledge; zero candidates is "valid and frequent". *Why:* "most sessions yield no new durable insight". (`src/commands/improve/extract-prompt.ts:44-105,279-311`; `src/assets/prompts/extract-session.md`)
509
+
510
+ - **Candidates the contract refuses are reported, not lost** — A candidate that breaks the contract is dropped and named in the result's warnings as `type:name dropped: <why>`, instead of failing the whole call. *Why:* #1047 (a lesson without `when_to_use` was silently dropped). (`src/commands/improve/extract-prompt.ts:313-360`; `src/commands/improve/extract.ts:676-677`; #1046, #1047)
511
+
512
+ - **One corrective retry on malformed output** — Each model call is retried once with a corrective instruction if no valid payload parses. After that the session is `malformed_model_output` (a top-level warning with the response length and SHA-256; under `--verbose` up to 200 characters of the malformed body are also logged after redaction). *Why:* not stated in code/docs. (`src/commands/improve/extract.ts:562-606,660-673`, `redaction.ts:489,501-508`)
513
+
514
+ - **Where proposals go and their refs** — Each candidate is minted as a proposal with `source: "extract"` and no judge verdict, so it is not stamped (extract never writes assets itself). Refs: `memories/<project>/<slug>`, `lessons/<project>/<slug>` or `knowledge/<slug>`, where `<project>` is the last path segment of the session's project hint. Frontmatter carries `description`, `when_to_use` (lessons), `confidence`, `evidence`, and `xrefs` to the session asset when one was written. *Why:* the queue is the only way to a live asset. (`src/commands/improve/extract.ts:319-349,703-719`)
515
+
516
+ - **Candidate dedupe through the ledger** — A candidate whose ref has a live `extract`-source ledger window (pending for 7 days, or recently rejected for 7 days) is skipped with a warning and not queued again. `--dry-run` reports `dry-run:<ref>` and queues nothing. *Why:* so the same insight is not queued repeatedly. (`src/commands/improve/extract.ts:682-701`; `src/storage/repositories/improve-ledger-repository.ts:78,82,142-163`; `src/commands/proposal/repository.ts:499-507`)
517
+
518
+ - **An accepted extract proposal leaves no ledger window** — Once accepted, an `extract` proposal's ledger row has no clock, so the same ref extracted from another session is minted again as an `update` proposal against the now-live asset, with no content-diff noise gate at mint. Dedupe is by the model-chosen ref, not by insight. *Why:* not stated. (`improve-ledger-repository.ts:159-161`, `repository.ts:427,435`)
519
+ - **Session asset (#561)** — By default (`indexSessions` on), a session at or above `minSessionDuration` (default 5 min; 0 disables; a missing timestamp counts as long enough) is also written as `sessions/<harness>/<id>.md`: a 2–4 sentence summary plus key topics, with `log_path` and an `access` hint to read the raw log. It is fail-open: no summary means no asset. It costs a second LLM call per processed session. *Why:* make sessions searchable by recall; the write is additive and uses no queue. (`src/commands/improve/session-asset.ts:5-10,114-120,155-193,210-236`; `src/commands/improve/extract.ts:83-85,612-632`; `src/core/config/schema/improve-processes.ts:201-211`) The summary call is pre-filtered and fenced against injection (fixed in this PR, #12), but its output is still written with `fs.writeFileSync` straight into the stash (not through the proposal queue or `writeAssetToSource`) as the asset's description and body, then indexed, so a summary the model was talked into is not reviewed. The owner confirmed on 2026-10-11 that writing it straight to the stash is fine, so there is no change. (`session-asset.ts:75-97,226`, `extract.ts:679`)
520
+
521
+ - **Per-run limit** — `maxSessionsPerRun` (default 25; 0 disables the cap) caps NEW sessions sent to the model. The rest are deferred to a later run with a warning. A session whose lock is lost at execution, or that is skipped by the under-lock re-gate, refills its slot; a session skipped at planning does not. `--session-id`, `--force` and a `--since` run are uncapped. *Why:* "the rest wait for later runs". (`src/commands/improve/extract.ts:87-88,490-529,818-831,1184-1188`)
522
+
523
+ - **Per-session lock** — A lock file per `(harness, session)` stops a session-end hook and the improve run from processing a session twice. Only a lock whose holder is dead (or an unreadable file, or one with no pid) is reclaimed; a live holder is never reclaimed by age, so a slow `processSession` (up to two extraction attempts at 600,000 ms each plus the session-asset summary call) keeps its lock and a racing hook skips the session as `locked_concurrent` (fixed in this PR, #3). The age option (`staleAfterMs`, reason `age_exceeded`) was removed from `probeLock`, so no age-based reclaim is left in `src/`, in line with the #872 lesson in `run-lock.ts`. A filesystem error proceeds without a lock. The session is re-read and re-gated under the lock. *Why:* "a session-end hook racing the hourly improve run"; an age window lets two runs process a live session twice (duplicate proposals) and makes the first holder's release a no-op. (`src/commands/improve/extract.ts:130-150,840-882`, `src/core/file-lock.ts:137-157,220-231`, `src/core/run-lock.ts:10-18`)
524
+
525
+ - **Improve-stage gates (`minNewSessions`) and defaults** — `processes.extract.minNewSessions` (default 0 = disabled) skips the whole stage when fewer new sessions exist (`below_min_new_sessions`), counted without reading session bodies. Then `akmExtract` runs once per available harness under the frozen plan, and a harness that throws becomes a warning. The shipped `default` strategy has `extract.enabled: false` and `triage.enabled: true, minScore: 2`. A direct `akm proposal extract` always runs regardless of the strategy toggle. *Why:* the toggle gates extract as an improve stage only; an explicit command always runs. (`src/commands/improve/preparation.ts:291-343,345-410`; `src/core/config/schema/improve-processes.ts:193-197`; `src/assets/improve-strategies/default.json:12`; `src/llm/feature-gate.ts:60-65`)
526
+
527
+ - **Timeout and credentials** — The per-session LLM timeout defaults to 600000 ms. Credentials are materialised once, after every read-only gate and before live state or a lock. *Why:* not stated in code/docs. (`src/commands/improve/extract.ts:967-973,1145-1146`; `src/commands/improve/extract-cli.ts:68-71`)
528
+
529
+ - **Status: Evolving (#1133)** — Session extraction was promoted from Experimental to Evolving in 0.10. It is not Stable because 0.10 is still settling the improve defaults. *Why:* the extract eval (akm-eval `evals/extract`, n=3, 2026-10-09) measured insights saved 1.00 terra / 0.97 qwen, routine sessions left empty 1.00, and planted instructions saved 0; the eval stays its gate. (`docs/plans/0.10-stabilization-plan.md:34`; `STABILITY.md:134,299-303`; `docs/reference/cli.md:2888-2890`; #1133, #1094)
530
+
531
+ ## Memory inference
532
+
533
+ (It runs as the post-loop maintenance step of `akm improve`; `src/indexer/passes/memory-inference.ts` is the pass and `src/llm/memory-infer.ts` is the single LLM call.)
534
+
535
+ - **Autonomy gate (off unless opted in)** — The `memoryInference` lane (derived children plus parent frontmatter rewrites) is disabled unless `experimental.improveAutonomy` is set. The gate downgrades the config and emits an `improve_skipped` event naming the lane and config key, so it is never a silent no-op. The shipped `default` strategy lists `memoryInference: {enabled: true}`, but on an install without the opt-in the gate turns it off and warns about it on each live `default` run, by design (Policy decision 8, resolved); with the opt-in the lane runs, as on the owner's install every night 10-09 to 10-11 (9, 21 and 4 facts). *Why:* the lane "writes derived memory children and rewrites parent frontmatter" without review. Memory inference stays gated after #1143. (`src/commands/improve/autonomy-gate.ts:26-30,41,54-57,113-116`; `src/commands/improve/improve.ts:1072-1081`; `src/assets/improve-strategies/default.json:11`; CHANGELOG 0.10.26101001 #1143)
536
+
537
+ - **Skip when too few are pending (`minPendingCount`)** — If `processes.memoryInference.minPendingCount` is set above 0 and fewer parents are pending, the pass is skipped and the plan reports it. Default is unset (no minimum). *Why:* not stated in code/docs. (`src/commands/improve/loop-stages.ts:611-618`; `src/commands/improve/improve.ts:780-787`; `src/core/config/schema/improve-processes.ts:180-183`)
538
+
539
+ - **Pending parents** — A pending parent is any `.md` under `<primary-stash>/memories/` (recursive) whose frontmatter has neither `inferred: true` nor `inferenceProcessed: true`. A file whose name ends in `.derived` is never pending, whatever its frontmatter says (so `<name>.derived.derived.md` chains cannot form). The pass discovers all pending parents itself; refs distilled this run are only a log hint. *Why:* the suffix is structural; the frontmatter flags are mutable. (`src/indexer/passes/memory-inference.ts:564-628`; `src/commands/improve/loop-stages.ts:599-612`)
540
+
541
+ - **Only the default akm bundle is written** — Read-only caches (git, npm, website) are untouched. The pass takes the source whose `isDefault` flag is true, never `sources[0]` (fixed in this PR, #8), in line with `AGENTS.md` ("Never infer the default/trusted bundle from array position"). With no default bundle it warns `memory inference: skipped — no default bundle among the sources…`, and with a default bundle that is not an `akm` adapter bundle it warns and names the path; either way it writes nothing. The write target is still hardcoded `name: "stash"`. *Why:* inferred children written to caches "would be clobbered by the next sync()"; a skipped pass should say so (the gate's "never silent" rule). (`src/indexer/passes/memory-inference.ts:440-455,660-675`, `autonomy-gate.ts:26`)
542
+
543
+ - **What is written: one derived memory per parent** — The LLM compresses the memory body into one derived memory: `title`, a one-sentence `description`, `content` (2–3 sentences), `tags` (3–8 in the prompt; schema min 1, max 8) and `searchHints` (3–6; schema min 1, max 6). It keeps names, versions, paths and config keys verbatim. The whole body is sent, because the 4000-char `MAX_BODY_CHARS` truncation was removed (otherwise the derived memory "looked complete" while the model saw a fraction). The temperature is the engine's or process's configured one, else 0.1. *Why:* compress a memory into a higher-signal memory for later retrieval. (`src/llm/memory-infer.ts:77-88,123-139`; `src/assets/prompts/memory-infer-user.md`; CHANGELOG 0.9.2x ("Memory inference uses the configured temperature") and "Content and memory bodies are no longer silently truncated")
544
+
545
+ - **Reply validation** — The reply is skipped (returns nothing and warns) if it is invalid JSON, if any of title, description, content, tags or searchHints is missing or empty, or if it is a prompt placeholder echoed back ("short title string", "tag1", etc.). Tags are trimmed and capped at 8, hints at 6. *Why:* "never return field descriptions or placeholder values". (`src/llm/memory-infer.ts:36-44,140-177`)
546
+
547
+ - **Derived file shape and naming** — The child is `memories/<parent>.derived.md` with frontmatter `inferred: true`, `captureMode: background`, `source: memories/<parent>`, `derivedFrom`, `description`, `tags`, `searchHints`, `title` and `updated`. The body is `# Title` plus the content. It is written through `writeAssetToSource`. The child carries no `createdAt`, so memory cleanup anchors relative dates on its file mtime. *Why:* `.derived` is a provenance marker on the same identity. (`src/indexer/passes/memory-inference.ts:659-717`; `.derived` inputs are skipped by `eligibility.ts:283-285`, `distill-promotion-policy.ts:129-131` and `memory/derived-ref.ts:46-47`; #882 in CHANGELOG)
548
+
549
+ - **Host-path warning (non-fatal)** — After any asset write, `writeAssetToSource` scans the content for `/home/<user>` or `/Users/<user>` prefixes (code fences are not exempt) and warns that the asset "embeds absolute host path(s)". The write is not blocked. This applies to derived memories because they go through `writeAssetToSource`. *Why:* absolute host paths make the stash non-portable and leak the local username. The advice is to prefer `$HOME` or `~`. (`src/core/write-source.ts:373-378,463-470`)
550
+
551
+ - **Parent stamping and idempotency** — After a successful write, the parent's frontmatter gets `inferenceProcessed: true` (a narrow, documented exception to the `writeAssetToSource` rule; body bytes are preserved). A parent whose frontmatter cannot be parsed is not stamped and retries on a later run. The stamp re-reads the file and rewrites the frontmatter from that fresh read, not from the `parent.data` parsed before the LLM call, so a frontmatter change made in that window (an accepted proposal, a feedback hash, a schema repair) is kept (fixed in this PR, #9). *Why:* so a later run does not call the LLM again; a rewrite of malformed frontmatter would produce duplicate-frontmatter corruption. (`src/indexer/passes/memory-inference.ts:18-20,719-758`)
552
+
553
+ - **Child-exists pre-check (#588, #550)** — Before any LLM or cache call, if `<parent>.derived.md` already exists on disk, the parent is marked processed and no attempt is used (`skippedChildExists`). A child that appears mid-flight, or a write throw, also counts there. A genuine write failure leaves the parent unmarked for retry. *Why:* #588: about 55% of the inference budget was spent rediscovering children that already existed. #550: parents were re-queued every run (about 37 wasted LLM calls per run on one stash). (`src/indexer/passes/memory-inference.ts:204-210,265-285,343-358,663-700`; CHANGELOG #588/#550)
554
+
555
+ - **Caching** — Results are cached in the index DB keyed by the parent's file path, a body hash and variant `memory-inference-v2`. An unchanged body gets a validated cache hit with no LLM call (`cacheHits`). `reEnrich` bypasses it (the improve step passes `false`). A corrupt cache entry falls through to recompute. *Why:* skip re-processing unchanged content; cache hits are tracked separately so the yield rate does not drift as the cache warms. (`src/indexer/passes/memory-inference.ts:210-221,304-332`; `src/indexer/db/llm-cache.ts:53-86`)
556
+
557
+ - **Retries** — The LLM client retries once on a retryable transient failure, after a jittered backoff of 200–800 ms. The retry is skipped if 90% of the timeout was already used. The retry counts as `retryAttempts`, not a failure. *Why:* "a single bounded retry for transient failures". (`src/llm/client.ts:144-182,300-330`; `src/indexer/passes/memory-inference.ts:289-291`)
558
+
559
+ - **Skips leave the parent pending** — If the LLM returns nothing usable (timeout, invalid, incomplete or placeholder reply), `skippedNoFacts` counts it and the parent is NOT marked processed, so it is retried on the next run. An HTML reply is counted in `htmlErrorCount`. The per-call failure never throws. An abort signal skips unstarted parents (`skippedAborted`). Credentials are checked up front only if at least one parent will dispatch to the model, so cache-only batches work offline. *Why:* "a transient LLM failure should be retried on the next index run". (`src/indexer/passes/memory-inference.ts:341,516-519,464-465`; `src/llm/memory-infer.ts:178-188`)
560
+
561
+ - **Concurrency and enablement layers** — The pass runs with the engine connection's `concurrency` or 1. The live gates are autonomy, then `processes.memoryInference.enabled`, then `minPendingCount`, then a resolvable runner; any off makes it a no-op. `index.memory.enabled` has no effect in 0.10: the only caller passes a runner, which forces it on, so there is no standalone `akm index` path. Existing derived children are never deleted. *Why:* "A pass runs iff both layers allow it." (`src/indexer/passes/memory-inference.ts:22-30,425-435,472-492`; `src/llm/feature-gate.ts:54`)
562
+
563
+ - **Post-pass indexing** — After inference, the improve run indexes exactly the files it wrote (`writtenPaths`, children plus stamped parents) with `indexWrittenAssets` instead of a full reindex. An index failure is only a warning. *Why:* writtenPaths comes from the run-scoped write-provenance journal, so it cannot drift from what hit disk. (`src/commands/improve/loop-stages.ts:538-554`; `src/indexer/passes/memory-inference.ts:134-142`)
564
+
565
+ ## Memory cleanup
566
+
567
+ (An autonomy-gated lane of the improve run: `src/commands/improve/memory/memory-improve.ts` is the analysis and the application; `preparation.ts` runs it before candidate selection. Archived retire-proposal targets use the same archive encoding.)
568
+
569
+ - **Analysis always runs; application is autonomy-gated** — Preparation analyses the primary stash's derived memories whenever there are eligible memories and the scope is `all`, the `memory` type or a memory ref (`shouldAnalyzeMemoryCleanup`), but applies the plan (belief transitions, archives, relative-date rewrites) only when the `memoryCleanup` lane is allowed (`experimental.improveAutonomy === true`). Without the opt-in the plan is only counted and the `cleanup` gate reports "memory cleanup archive application is autonomy-gated". Archived refs are dropped from the planned set, and a reindex follows any archive or belief transition. *Why:* the lane archives and rewrites memories without a person in the loop. (`preparation.ts:551-594`, `improve.ts:640-643`, `eligibility.ts:361-370`, `planner.ts:56-76`, `autonomy-gate.ts`)
570
+ - **Only derived memories with a resolvable parent are analysed** — The pass walks `memories/` and keeps files that are derived (`inferred: true` or a `.derived` name) and whose parent ref resolves. Families are grouped by parent. An incomplete directory scan only warns. *Why:* a derived memory is machine-generated, so it can be pruned without losing authored text. (`memory-improve.ts:942-1003`, `memory/derived-ref.ts:45-48`)
571
+ - **Prune reasons** — A derived memory is archived when its `supersededBy` names an existing memory (`superseded-derived`), when it is marked `obsolete` or `retracted` (`obsolete-derived`), or when it duplicates another derived memory of the same parent by content fingerprint (`duplicate-derived`). Among duplicates the survivor is the canonically named child, then the one with the higher signal score, then the lower ref. Contradicted memories are excluded from duplicate detection. *Why:* not stated beyond the reason names. (`memory-improve.ts:139-147,164-180,1072-1088,1121-1127`)
572
+ - **Contradictions are resolved by graph position** — `contradictedBy` edges inside one parent family form a graph; strongly connected components are computed, and a memory in a component with an outgoing edge is marked `contradicted` with `currentBeliefRefs` set to the sink (current) components it reaches; sink components are refreshed to `active` (or kept `asserted`). `deprecated` is frozen and never refreshed; `superseded` stays refreshable. *Why:* the surviving belief is the one nothing contradicts. (`memory-improve.ts:355-508,482-484`)
573
+ - **Consolidation candidates are only reported** — Derived memories of one parent sharing a normalised title/description/first-hint signal are reported as consolidation candidates with a suggested survivor; nothing is applied. (`memory-improve.ts:182-204`)
574
+ - **Relative dates are rewritten to absolute ones** — On every derived memory the patterns `yesterday`, `last week/month/year` and `N days/weeks/months ago` are flagged, and on apply they are rewritten in the body to ISO dates. The anchor is the frontmatter `createdAt`, else the file mtime. Memory inference stamps no `createdAt`, and belief transitions are applied first in the same pass, so a derived memory whose frontmatter was just rewritten resolves its relative dates against today. *Why:* M-5 / #396 (document creation time as the reference). (`memory-improve.ts:206-219,270-296,325-352`, `memory-inference.ts:702-716`)
575
+ - **Application order and the audit trail** — Belief transitions are written to the memory's frontmatter and appended to `.akm/memory-cleanup/belief-transitions.jsonl`; prunes are archived next; relative dates are rewritten last. An archive writes the `cleanup.md` tombstone (reason, survivor, previous belief state) before moving the file to `.akm/memory-cleanup/archive/`, so a crash leaves at worst a tombstone for a move that has not happened. Every write and both ends of a move go in the write-provenance journal. *Why:* recoverable cleanup: the same encoding serves retire-proposal accepts, and the purge sweep deletes archived bytes only once git holds them. (`memory-improve.ts:233-308,632-701,893-896`)
576
+
577
+
578
+ ## Consolidate
579
+
580
+ Paths are relative to the repository root of this branch. `consolidate.ts` is `src/commands/improve/consolidate.ts`.
581
+
582
+ - **Promote-only plan** — The model plan holds only `promote` ops; anything else in its output is dropped as an invalid op. Merge, delete and contradict were removed in 0.9.17-alpha.1. *Why:* they ran in production and "cost thousands of completion tokens". (`consolidate.ts:97-101,144-151`)
583
+ - **One promotion per source memory** — If several chunks propose the same memory, the last chunk's op wins. (`consolidate.ts:104-108`)
584
+ - **Promotion never touches the memory directly** — It mints a `consolidate` knowledge proposal with `promotionSource` and `promotionSourceHash` (the raw-body hash). Accepting it later archives the source memory and its `.derived` twin, but only if the source's body hash still matches. A source with no recorded hash, or one edited since, is left alone with a warning. *Why:* so an accepted promotion no longer leaves a memory/knowledge duplicate, and so an edit made after queuing is not silently archived. The raw (not sanitized) hash is used so an unedited memory does not read as changed. (`consolidate.ts:1060-1065,1139-1159`; `src/commands/proposal/repository.ts:1483-1530,1594-1595`)
585
+ - **Pool: eligible memories** — The pool is on-disk memories in the target bundle, excluding any name ending `.derived`. The index is read first; if it is empty or missing, `memories/` is walked from disk. (`consolidate.ts:110-112,1168-1215`)
586
+ - **Pool: revisit window** — A memory with a prior `consolidate` ledger row is skipped. If the row is content-driven (accepted, rejected or `judged_no_action` with a recorded body hash), it returns only when its body hash changes. Otherwise the 7-day revisit cadence applies. A signal newer than the last attempt lifts a soft window, but never a rejected, quality_rejected or expired one. *Why:* the same text would be judged again with the same answer. Accepted promotions used to be eligible at once and rejected ones after 7 days. A judged-but-not-promoted memory gets a hashed `judged_no_action` row, which has no clock, so a transient refusal (`promote_pending_proposal_exists`, `promote_dedup_window`, `promote_already_exists`) holds the memory until someone edits it, even after the blocking proposal is rejected or the file removed; only `promote_create_failed` is exempt (the comment "waits out its revisit window (or its next edit)" is stale since #998). (`consolidate.ts:553-579,921-936,1043-1053,1131-1138`; `src/storage/repositories/improve-ledger-repository.ts:82,111-130,136-160,180,191-196`; #998)
587
+ - **Pool: already promoted or rejected** — A memory whose promotion was accepted or rejected is held until its body (not its frontmatter) changes. A separate check also drops any memory whose body hash matches a rejected `consolidate` proposal, whatever the memory is called. Only rejections decided on or after 2026-09-29 count (`REJECTED_BODY_HOLD_FROM`, a production constant encoding one stash's audit history; it gates only this "same body under any name" hold). *Why:* the 2026-08-02 and 08-18 bulk audits rejected hundreds wholesale and are "not a verdict on the memory's text". A rejected row recorded before the hash existed keeps the 7-day window; an accepted row without a hash has no window and is eligible at once. (`consolidate.ts:291-319,580-590`; #998)
588
+ - **Pool: retrieval scope (90 days)** — Only memories that a user `search`, `curate`, `show` or `feedback` event returned or named within the usage window, or newly captured material no improve stage has processed, are eligible. The window is `USAGE_EVENT_RETENTION_DAYS` = 90. A `.derived` hit counts for its parent. If state.db is unreadable, everything is eligible. *Why:* the loop was rewriting assets nobody reads (68% of live assets rewritten; 70% never appeared in a result). (`consolidate.ts:592-598`; `src/commands/improve/retrieval-scope.ts:27,45-84,91-101`; `src/indexer/usage/usage-events.ts:201`; #986)
589
+ - **Pool: verbatim-in-knowledge pre-filter** — Before the limit is applied, a memory whose body hash equals any `knowledge/*.md` body is dropped (counted as `prefilteredAlreadyPromoted`). *Why:* so the cap picks from memories the run can act on; the index may lag a just-written asset, so `knowledge/` is read from disk. (`consolidate.ts:321-348,605-619`)
590
+ - **Pool: limit** — If `limit` is set and exceeded, the oldest-modified memories are kept up to the limit. With no limit and more than 150 memories, a warning suggests adding one; it fires on every default run over 150 memories (no shipped strategy sets a limit), is evaluated before the knowledge-body pre-filter, and its timeout rationale is moot under `akm improve`, where the budget cap defers overflow. *Why:* to prevent timeouts on slow LLM endpoints. (`consolidate.ts:600-627`)
591
+ - **Chunk size** — A chunk holds `floor((contextLength − 2000) / ceil(500/3))` memories, clamped to 1…`maxChunkSize` (default and cap 50). Each memory shows 500 body characters, frontmatter stripped. `contextLength` defaults to 4096 tokens when the engine sets none, which gives about 12 memories per chunk. *Why:* conservative on purpose, because an agent CLI prepends its own large prompt. Frontmatter can outrun the truncation and leave the model judging metadata. (`src/commands/improve/consolidate/chunking.ts:12-29,50-66`; `consolidate.ts:773-780`)
592
+ - **Chunk ordering** — Memories are ordered by a greedy nearest-neighbour chain over description+tag embeddings (cached in `body_embeddings`), so similar ones share a chunk. Original order is kept with fewer than 3 memories, no embedding config, or an embed failure (see Consolidate depends on embeddings). (`consolidate.ts:190-270`)
593
+ - **Budget cap with a fixed 30 s per chunk** — If the budget signal reports `remainingBudgetMs`, safe chunks = `floor(budgetSeconds/30 × 0.6)`. Anything beyond `safeChunks × chunkSize` is deferred: the oldest-modified memories are kept and the newest are deferred. *Why:* `p90ChunkSecondsDefault` was removed in #1131 because results were identical at 10, 30 and 90 s under a 180 s budget; it only ever trims the promote pass. (`consolidate.ts:755-793`; `CHANGELOG.md:179-187`; #1131)
594
+ - **Chunk loop abort rules** — A chunk is not retried (the transport already retries once). The loop stops cleanly when the budget signal aborts. It aborts once at least 4 chunks have been processed and 50% or more of them failed ("LLM may be unavailable"). Every chunk goes to the model, including one made only of `captureMode: hot` memories (changed in this PR, decision 5). A memory that a pending consolidate proposal already carries (same body hash) is annotated "(already queued)" in the prompt, and the system prompt forbids promoting "already queued" ones; nothing is annotated for `captureMode`. (`consolidate.ts:642-690,688-751`; `chunking.ts:31-35,57`; `src/assets/prompts/consolidate-system.md:4`)
595
+ - **Hot memories are treated like any other (changed in this PR, decision 5)** — There is no hot-specific code in consolidate: the pair pass has no `captureMode: hot` retire guard, the chunk loop has no all-hot skip, the chunk prompt carries no hot annotation, and `hasHotCaptureMode` and `isHotCapturedMemory` no longer exist. A `captureMode: hot` memory (what `akm remember` writes) can therefore be retired by the pair pass, promoted to knowledge by the promote pass, and archived when the promotion is accepted, subject to the same checks as any memory. Nothing else in consolidate reads `captureMode`. *Why:* the owner decided that hot memories may be promoted and archived; the old protection was inconsistent anyway (promotion was never blocked, and an all-hot chunk got a permanent `judged_no_action` hold). (`pair-pass.ts:765-773`, `consolidate.ts:642-646,798`, `chunking.ts:31-35,57`, `repository.ts:1504-1515`)
596
+ - **Accounting invariant** — Every memory lands in exactly one bucket, so `processed == promoted + judgedNoAction + Σ skipReasons + failedChunkMemories`. (`consolidate.ts:16-17,444-470`)
597
+ - **Ledger write after the pass** — Every judged memory that was not promoted gets a `judged_no_action` row with its body hash. A promotion that failed to persist (`promote_create_failed`) gets no row and is retried next run. (`consolidate.ts:919-936`)
598
+ - **Promotion refusal reasons** — A promotion is skipped, with a skip reason, if:
599
+ - it was already promoted this run;
600
+ - a pending proposal exists for the same concept;
601
+ - the knowledge file already exists;
602
+ - the source is unreadable or fails sanitization;
603
+ - the source has `status: superseded`;
604
+ - the source body is under 100 characters;
605
+ - the body hash matches existing knowledge (`dedup_existing_knowledge`);
606
+ - the body hash matches a pending consolidate proposal;
607
+ - it is already covered by a knowledge doc (next rule);
608
+ - there is no valid description;
609
+ - it is a slug variant of a pending proposal (dates, counters and word order folded away).
610
+
611
+ Only a consolidate proposal must carry a non-empty frontmatter description. Why: not stated in code for most of these. The slug and body dedup checks are the mint-time duplicate checks, and the superseded check is also a validator ("superseded memories are not promotable knowledge"). (`consolidate.ts:1004,1015-1166`; `src/commands/proposal/repository.ts:399-407`)
612
+ - **"Already covered" skip (coverage gate)** — A memory is covered when at least 30% of its distinct 5-word shingles appear in one of the 20 nearest knowledge docs by stored vector in its bundle (`PAIR_NEIGHBOR_FETCH_K`). The skip reason is `dedup_covered_by_knowledge`. With no stored vector the gate does nothing, so under the shipped `semanticSearchMode: off` it is inert. *Why:* 215 of 224 rejections said duplicate, covered or overlapping. The 0.5 cut caught paraphrases poorly (18 of 51 later-rejected promotions held at least 30%). 0.2 would also have skipped 2 of 103 accepted ones, so 0.3 sits between the two measured points. Recall over the 20 nearest docs is unmeasured. (`src/commands/improve/consolidate/coverage.ts:5-47,58-60,136-152`; `consolidate.ts:1106-1114`; #998)
613
+ - **Sanitization of model/source content** — The sanitizer strips an outer code fence, removes `<think>`, tolerates under 300 characters of preamble before frontmatter, recovers a missing closing `---`, and re-serializes the YAML. It replaces placeholder `updated: today|now|{today}` with the real date. Failures return a reason such as `UNBALANCED_CODE_FENCE`, `MISSING_FRONTMATTER_SENTINEL`, `MALFORMED_FRONTMATTER_BLOCK` or `INVALID_YAML`. *Why:* defence against three recurring LLM output defects. (`src/commands/improve/consolidate/sanitize.ts:5-9,58-111,119-140`)
614
+ - **Consolidate depends on embeddings, which are off by default** — The pair pass (neighbour lookup by stored vector), the coverage gate, the continuity check and the chunk ordering all need stored vectors or an `embedding` block, and `semanticSearchMode` defaults to `off`, which stores none. With embeddings off the coverage gate is inert, chunk ordering keeps the original order when there is no embedding config, and the pair pass skips every initiator that has no stored vector, leaves it unsettled (no ledger row; see Pair ledger row) and adds a warning that names the count and says to set `semanticSearchMode` to `auto` and run `akm index` (fixed in this PR, #2). The result also shows `pairsConsidered: 0`. *Why:* a pair cannot be judged without a neighbour, and the warning says what to change. (`materialize-embeddings.ts:147-150`, `index-vec-repository.ts:172-183`, `coverage.ts:43-46`, `pair-pass.ts:470-475,938-942`)
615
+ - **Pair pass runs regardless of the promote pool** — It has its own initiator and candidate selection, over memories (any depth, including `.derived`), flat knowledge, and lessons, and keeps its own counters (`pairPass`). It needs a bundle id and an LLM runner or it returns empty. (`consolidate.ts:854-859`; `src/commands/improve/consolidate/pair-pass.ts:198-235,897-915`)
616
+ - **Pair pass initiators** — An asset is an initiator if it is in retrieval scope and has no prior `consolidate-pair` ledger row or a different body hash. The pair-pass ledger source never uses a timer, so eligibility is purely content-driven. A pair-pass row does not mark an asset "processed" for retrieval scope. "Created" is the git first-add time (renames followed), with mtime as fallback. *Why:* the pair pass judges against neighbours, not on an asset's own merits, so its attempt is not usage evidence. (`pair-pass.ts:19-28,244-342,422-448`; `retrieval-scope.ts:60-75`; `improve-ledger-repository.ts:84-101,142-146`)
617
+ - **Pair pass cosine floors** — The floor is `T_PAIR` = 0.93 for a changed-content initiator or one git-added within 7 days (`NEW_MATERIAL_DAYS`). It is `BACKFILL_FLOOR` = 0.95 for backlog initiators with no prior row. *Why:* the 0.90–0.93 band alone graded 3 of 4 right against the owner, so the floor was raised from 0.90 to 0.93. The existing backlog goes at 0.95 first. (`pair-pass.ts:14-18,84-93,478`)
618
+ - **Pair candidates** — 20 nearest neighbours are fetched per initiator and the first 5 that pass the filters are kept. Filters: same bundle; type memory, flat knowledge or lesson; file exists; not itself; not its own `.derived` twin or parent; cosine at or above the floor; pair deduplicated by unordered key. A `.derived` memory is not excluded as a candidate, and the guards below inspect the retired side only, so it can be the kept side. *Why:* S2: a few self/twin/bundle/tier misses in the raw top 5 starved initiators to zero candidates. (`pair-pass.ts:80-83,176-196,455-508`)
619
+ - **Pair budget and admission** — At most 300 pairs are judged per run (`MAX_PAIRS_PER_RUN`). Whole initiators are admitted, never single pairs, first-fit. New-or-changed initiators go before any backlog initiator, then by best cosine. A group with any candidate touching an asset in a pending retire proposal (as retired or successor) is skipped before it spends budget. The retire guards run after the judge call with no pre-filter, so budget is also spent on pairs whose only retire outcome would be refused (a `.derived` twin whose parent exists, whether in a pair of twins or flipped against knowledge). *Why:* the old flat top-300 cap left initiators partially judged forever; simulation: 30 nights made 9,000 calls but completed only 455 pairs, and whole-initiator admission drains the backlog in about 12 nights. (`pair-pass.ts:94-95,944-997`)
620
+ - **Pair ledger row** — An initiator gets a ledger row (`proposed` or `judged_no_action`, with body hash) only if all its candidates were admitted and none failed. Otherwise the next run reconsiders it. An initiator with no stored vector never gets a row, because the neighbour query could not run for it (`hasStoredEmbedding`); it is counted in the pair-pass warning, so turning embeddings on later reconsiders that backlog (fixed in this PR, #2). An initiator that was queried and has zero candidates still qualifies (`if (!hasCandidates) return true`) and is stamped `judged_no_action` with its body hash, a content-driven hold with no clock. *Why:* a row means the asset was judged against its neighbours; without a vector nothing was. (`pair-pass.ts:1057-1075`, `index-vec-repository.ts:180-183`)
621
+ - **Judge labels and what each means** — Labels are `duplicate` (both claim lists empty), `subsumed` (exactly one list empty), `supersedes` (B replaces a claim in A and `onlyInA` is empty), `contradicts`, `overlap`, `unrelated`. The judge sees up to 12,000 characters per side (the cut is silent: nothing on the proposal's retirement metadata marks truncation, so a side can be retired with its tail unseen, contradicting "deletes a note only when the other keeps every claim"). Claim lists are durable claims (fact, value, command, path, number and so on). A side may be retired only if the judge listed nothing that side alone holds. *Why:* akm deletes a note only when the other keeps every claim; the lists make that safety explicit. 64% of the old judge's retirements lost nothing, against 92% with the lists. (`src/assets/prompts/consolidate-pair.md:1-19`; `pair-pass.ts:96-100,602-615`; `CHANGELOG.md:1096-1097`)
622
+ - **Replacement rule (same named subject)** — B replaces A's claim only when both are about the same named thing (service, integration, partner, component, region or file) AND B gives a current new value, meaning B says so (now, instead of, since, renamed, corrected, no longer) or B is created later. Notes about different named things never replace each other, whatever their dates. A different value for the same thing with neither sign is a conflict: both values are listed and the label is `contradicts`. *Why:* #1146 — gpt-5.6-terra retired 10 of 60 notes holding a claim the survivor lacked, all from `contradicts` pairs it labelled `supersedes`, because akm called B "newer" when the dates tied. #1149 — a bare later date then made gpt-oss:120b retire notes about different named things (Alder vs Birch partners). Measured unsafe retirements went 1, 2, 1 → 0, 0, 0 on gpt-oss per case, but the pool run on gpt-oss went 0, 1, 0 → 0, 1, 1 and terra was not rerun (PR #1149). (`consolidate-pair.md:9,14-17`; #1134, #1146, #1149)
623
+ - **Tied dates** — Dates come from git first-add. When they are equal, the prompt labels the sides "A (same age as B)" / "B (same age as A)" instead of older/newer. *Why:* so "B (newer)" is not read as proof that B replaces A. (`pair-pass.ts:566-583`; #1134, #1146)
624
+ - **Retire outcome table** — `duplicate` and `supersedes` retire the older and keep the newer. `subsumed` retires the side the judge names `redundant` and keeps the other (`redundant: "A"` retires the older, `"B"` the newer); nothing is minted if the pointer is missing or invalid. A side is retired only when the judge listed nothing that side alone holds. `contradicts`, `overlap` and `unrelated` mint nothing (`contradicts` is only counted). The tombstone reason maps `supersedes` to `superseded`. *Why:* owner-graded calibration replaced the original "shorter body" rule; the combined retire class graded 20/22 = 0.91. (`pair-pass.ts:14-17,586-615,751-752`, `src/assets/prompts/consolidate-pair.md:13,19`)
625
+ - **Knowledge is never replaced with a memory** — If the retired side would be a knowledge note and the successor a memory (a `.derived` memory counts as a memory), the pair is flipped to retire the memory instead, but only if the judge lists no claim held only by the memory. Otherwise nothing is minted. The flipped pair then runs every guard like any retirement. *Why:* owner rule 2026-10-08: knowledge is the reviewed form and a memory is raw capture, often the memory the note was promoted from. 21 such retirements had been accepted since 2026-10-01. (`pair-pass.ts:757-763`; #1092)
626
+ - **Retire guards** — Never retire a `.derived` memory whose parent still exists. Within a run, an asset used as a retired side or successor cannot be retired or reused again (same-run chain guard). The "never retire a `.derived` whose parent exists" reasoning is not applied to successors: `foo` can be retired in favour of `bar.derived`, and `bar` later retired against `X`, so `foo`'s claims leave the live tree with no judge having compared `bar.derived` to anything. A retire-worthy verdict dropped by the chain guard counts as failed, so its initiator gets no ledger row and is retried. A pair whose refs and both body hashes match a rejected or reverted retire proposal is skipped before the judge call, except one rejected only by expiry or as a stale target. *Why:* night 1 judged 177 duplicate verdicts into only 59 proposals, so silently abandoning the dropped ones was a bug. A reverted retirement is the same "no, not this" signal as a rejection. (`pair-pass.ts:388-411,713-726,765-789`)
627
+ - **Continuity-risk check (forgetting safety)** — Before minting, up to 5 of the retired asset's most recent cleaned past queries are replayed through the real search with limit 10. For every query where the retired asset ranked in the top 10, the successor must also rank in the top 10. Failures, and unverified queries (the search threw, or fell back to keyword-only, including every later query once an earlier one fell back), set `continuityRisk` on the proposal. It still mints, but is excluded from all bulk accept and from drain staging. No recorded queries, or normalized-identical bodies, means no check and no risk. A flagged proposal has no recovery path: an embedding outage during the pair pass flags every retire proposal minted that night, they can only be accepted by id, no command re-runs the check (`checkRetirementContinuity` is called only from the pair pass), and reopen preserves the flag, so one bad night means about 90 individual accepts. *Why:* this is rule R3, the forgetting-safety lane's purpose moved to a per-proposal search-rank replay. An unverified query must never read as "no risk found". A dead embedding endpoint would otherwise cost about an hour (S2). (`src/commands/improve/consolidate/continuity-check.ts:5-16,26-29,135-192`; `pair-pass.ts:797-809`; `src/commands/proposal/proposal.ts:536-543`)
628
+ - **Which past queries are replayed.** They come from the usage log: distinct text of user `search` and `curate` events that returned the retired ref, most recent first. They are whitespace-collapsed and deduplicated, capped at `MAX_QUERIES = 5`, and anything over 2,000 chars is dropped. Harness or tool envelopes and the stash README boilerplate are excluded. The cap and the 2,000-char drop are the helper's own constants (kept from the removed reflect retrieval gate): its comment says the retrieval suite treats a longer input as a paste, but `non-task-input.ts` says the opposite ("Length is not a signal": prompts over 2,000 characters found relevant assets at about the rate of shorter long prompts). (`src/commands/improve/retrieval-queries.ts:15-39`; `src/indexer/usage/usage-events.ts:180-198`; `src/core/non-task-input.ts:8-20`)
629
+ - **Second look (`confirmNothingLost`)** — One more call asks only what the retired note holds that the kept one lacks. It is true only on a clean, empty `missing` list. A failed call counts as not confirmed. *Why:* "it caught 2 of 4 duplicates the judge got wrong, and held back none of 109 right ones". (`pair-pass.ts:676-701`)
630
+ - **Gate staging (when a retire proposal is staged)** — The proposal is always minted `pending`. It is stamped `staged` (gate `consolidate-pair`, reason = the judge label, content hash) only when all three hold:
631
+ - for a `duplicate`, both claim lists are empty;
632
+ - there is no continuity risk;
633
+ - the second look confirmed nothing is lost.
634
+
635
+ There is no confidence cutoff. The judge's `confidence` is stored on the proposal for reviewers and nothing gates on it. Anything not staged waits for a person. *Why:* replay precision 336/360 (duplicate 0.98, subsumed 0.92, supersedes 0.875, 2026-10-07). In production, unstaged subsumed retirements were accepted 45 of 54 times by hand. That replay (0.9.27-alpha.2) predates the replacement-rule fixes (#1146/#1149, 0.9.31 and later); with about 90 staged retirements auto-accepted nightly, the unattended error volume is to be re-measured on the shipped prompt (Policy decision 9). (`pair-pass.ts:837,844-868`; `CHANGELOG.md:762-774`)
636
+ - **Retire proposal separation** — Retire proposals use source `consolidate-pair`, apart from `consolidate` promotions, so `accept --generator consolidate` never sweeps a retirement. The retire proposal records `onlyInRetired`/`onlyInSuccessor` and both body hashes. Retire proposals never mint a `proposed` ledger row. (`pair-pass.ts:825-843`; `src/commands/proposal/repository.ts:514-546`)
637
+ - **Retirement is archive, not delete** — Accepting a retire proposal moves the retired file and its `.derived` twin to `.akm/memory-cleanup/archive/` with a tombstone. `akm proposal revert` restores the bytes exactly while the archived copy exists; on a git-backed bundle the purge sweep deletes it 30 days after retirement, after which revert rewrites the primary from the proposal's `backupContent` (fixed in this PR, #11). A `supersedes` label writes a supersede edge on the retired side after the accept intent is recorded. The accept is a resumable three-phase sequence (record intent, move, finalize). (`src/commands/proposal/repository.ts:1775-1791,1856-1920`; `src/commands/proposal/proposal.ts:320-323`)
638
+ - **A retire proposal expires, and a stale one is rejected once** — A retire proposal expires by age like any other (default 90 days; fixed in this PR, #10), but it is still never orphan-purged (only `reflect` proposals are), and only *staged* ones are attempted by the drain. A staged one whose target or successor is gone, or whose bodies changed since judging, fails with a message `categorizeDrainFailure` classifies as stale-target, so the drain rejects it once (gate `drain`, reason `stale-target`) instead of listing it `failed` (`promote-error`) every run. Expiry and stale-target rejections are procedural: `loadRejectedPairKeys` skips them, so they do not settle the pair and it can be judged again. While a retire proposal is pending, the pending-retire set holds both the retired ref and the successor, and the pair pass drops the *whole initiator group* touching either; only expiry, the stale-target rejection, or a person (`akm proposal accept` by id, or `reject`) clears it. *Why:* an immortal retire proposal froze consolidation around its survivor; expiry and a stale-target rejection are not a person saying no. (`repository.ts:828-870,872-927`, `drain.ts:142-150,194-208,575-584`, `pair-pass.ts:388-408,948-959`)
639
+ - **Retire accept freshness** — Accepting refuses with a stale error unless the successor still exists and both sides' body hashes equal those recorded at judge time. This is the durable half of the chain guard. (`repository.ts:1744-1772`)
640
+
641
+ ## Triage and drain
642
+
643
+ Paths are relative to the rules-010 checkout. `drain.ts` is `src/commands/proposal/drain.ts`.
644
+
645
+ - **Drain is the one rule for the pending backlog** — Everything pending is decided by one pass shared by `akm proposal drain` and improve's triage pre-pass. The pre-pass is skipped for a single-ref scope. (`drain.ts:5-28`; `src/commands/improve/improve.ts:852-879`)
646
+ - **applyMode queue vs promote** — `queue` never promotes: judged accepts are only stamped `staged`. `promote` accepts. The mode is `--promote`, else the selected strategy's `applyMode`, else `queue`. Standalone `proposal drain` with no `--strategy` resolves the strategy as `defaults.improveStrategy ?? "default"`, and `default` sets `applyMode: promote`, so on a stock install it resolves to **promote** and hits the destructive confirmation (non-interactive without `--yes` throws `NON_INTERACTIVE_REQUIRES_YES`); only a strategy with no `applyMode` falls back to `queue`. The `--promote` help text "Default is queue mode" is stale for the same reason. The shipped `default` strategy sets `applyMode: promote` with judgment off. *Why:* #1143 — the nightly baseline (`proposal drain --promote` without judgment) solved 0.91 (terra) / 0.89 (qwen) of items with 0–1 harmed, and the judged drain is what caused harm. (`drain.ts:81-82,444-464,638-657`; `src/assets/improve-strategies/default.json:15`; `src/commands/proposal/proposal-cli.ts:436,457,461,471-482`; `src/cli/confirm.ts:68-75`; `improve-strategies.ts:111-112`; #1143)
647
+ - **maxAcceptsPerRun default 200** — This is the cap on promotions across both tiers. Fallbacks in `improve.ts` and `proposal drain` are also 200, and `--max-accepts` overrides it. The plan field is required. *Why:* raised from 25 (#1143) because the pair pass stages about 90 pairs a night on a real stash, so a lower cap let the review queue grow every night (129 pending on the owner's stash). The cap stays as a flood guard. (`default.json:15`; `src/commands/improve/improve.ts:844,867`; `proposal-cli.ts:465`; `CHANGELOG.md:37-39`; #1167, a figure not recoverable from the repository)
648
+ - **skippedByCap** — Accepts beyond the cap are listed as `skippedByCap` (an info line is logged) and stay pending. The judgment tier gets only the remaining budget, `cap − promotedHere`. In `queue` mode the deterministic accepts are neither promoted nor reported, yet `skippedByCap` is computed and the info line prints "N promoted", so `triage_drained` under-reports. (`drain.ts:629-636,663-665,465-468`)
649
+ - **Deterministic tier: empty diff** — A proposal with no non-blank body line outside the frontmatter is auto-rejected as `empty-diff`. (`drain.ts:133-140,591-592,619-627`)
650
+ - **Deterministic tier: judge-passed content** — A proposal whose gate decision is `staged` with a content hash equal to the current content hash is accepted. The reason is `judge-passed` for the generating stage's quality gate and `judgment-accept` otherwise. An edited proposal no longer matches and falls to the judgment tier. (`drain.ts:597-603`; `src/commands/improve/stage.ts:241-286`)
651
+ - **Deterministic tier: staged retirements** — A retire proposal is accepted only if staged by the `consolidate-pair` gate, its staged hash matches, and it has no `continuityRisk`. Every other retirement waits for `akm proposal accept` by id. Together with the previous rule this is why "deterministic" means only that the hash still matches: the tier accepts content a judge staged at mint (an LLM verdict) and retirements the pair pass staged. *Why:* retirements are the unsafe kind to judge (#1132), so only the pair pass's own second-look staging counts. (`drain.ts:569-585`)
652
+ - **Other gates' decisions stand** — A proposal another gate already auto-rejected is left as is. A proposal another gate deferred (a hand-off to a person) is also left for that person; a deferral the drain itself stamped (gate `triage`) is re-triaged. (`drain.ts:586-590`)
653
+ - **Stale target is auto-rejected, not retried** — If an accept fails because the target changed (or was created) after mint, the proposal is auto-rejected once as `stale-target`, never overwritten. The ledger records it as `failed`, so the ref can be re-proposed. A dry run exercises the same preflight. A retire proposal's own staleness error ("is stale — successor … since judging", or a target that no longer exists) matches the stale-target pattern as well, so it is rejected once instead of staying pending (see Consolidate: A retire proposal expires, and a stale one is rejected once; fixed in this PR, #10). (`drain.ts:142-211`; `src/commands/proposal/repository.ts:715-727,1753-1773`)
654
+ - **Proactive-lane proposals deferred** — A proposal with `eligibilitySource: "proactive"` (no feedback behind it) is never accepted by a deterministic gate, an earlier staged accept, or the judgment tier. It is deferred with reason `proactive-needs-review` and not sent to the judgment tier. Feedback-driven proposals (`signal-delta`, `scope`) are unchanged. *Why:* with the proactive lane on by default (#1129) and the default drain promoting, a reflect edit on an asset with no feedback was accepted unattended; the nightly eval showed `when_to_use`, `type`, provenance stamps and a title heading added to assets no one asked about. (`drain.ts:13-16,593-596,386-391,676-677`; `CHANGELOG.md:164-170`; #1147)
655
+ - **Judgment tier is for consolidate promotions only** — A judge accept becomes a real accept only for `source: consolidate` with a `promotionSource`. Any other kind (retirement, reflect revision, and so on; a manual exact fix never reaches the tier, see Exact fix) is left for a person with reason `judgment-not-promotion`, whatever the strategy says. A staged judgment accept from an earlier drain obeys the same rule. *Why:* judging every kind solved about half the items (0.49 terra / 0.53 qwen), harmed 14 / 11, and changed 18 / 21 notes outside the planted set. Promotions alone judged reasonably (qwen precision 0.87, recall 0.78, 4.8% of bad promotions accepted; terra precision 1.0, recall 0.17). (`drain.ts:20-24,128-131,435-443,599-602`; `src/commands/proposal/proposal-cli.ts:408-413`; #1132)
656
+ - **Judgment is off in every built-in strategy and gated by the autonomy opt-in** — No shipped strategy enables triage judgment. The autonomy gate downgrades `applyMode: promote` to `queue` only when judgment is also on, unless `experimental.improveAutonomy` is set. The downgrade is reported (`improve_skipped`, `akm task doctor`), never silent. A deterministic-only promote needs no opt-in. `memoryInference` and `memoryCleanup` stay gated. Enabling judgment with no engine throws `LLM_NOT_CONFIGURED`. (`src/commands/improve/autonomy-gate.ts:14-30,117-127`; `src/commands/improve/improve-strategies.ts:498-518`; #1132, #1143)
657
+ - **No judgment runner means `needs-judgment` stays pending** — Anything not decided deterministically gets deferred reason `needs-judgment`. With no runner it stays pending, the ledger records `review_needed` with reason `no-judge-configured`, and the drain emits `triage_deferred` ("no judgment runner configured"). Undecided items must never look like full success. (`drain.ts:604-607,672-691,714-727`)
658
+ - **Judgment verdict handling** — The judge returns accept, reject or defer, and parse failures and runner errors leave the item deferred (`judgment-parse-failure`, `judgment-error`). A judge reject becomes an auto-reject (`judgment-reject`). The judge sees the live asset (for a promotion, which is a create, the prompt prints "(none — this proposal would create a new asset)"), same-ref sibling proposals, and, only for such a create, the 5 nearest knowledge notes (300-character excerpts, plus promotions accepted earlier in the same drain). It is told to reject a promotion those notes already cover, or one that reports changing state ("as of <date>", a rollout, a plan not yet carried out). A lesson drawn from an incident is durable. *Why:* the model never sees `knowledge/` otherwise, and 18 of 51 promotions minted on 2026-10-07 and later rejected held 30% or more in a neighbouring doc. (`drain.ts:253-306,371-497,499-534`; `src/commands/improve/consolidate/coverage.ts:154-186`)
659
+ - **Judgment queue-mode staging** — In `queue` mode a judged accept is stamped `staged` (`judgment-accept`) with its content hash, for a later promote run. (`drain.ts:444-464`)
660
+ - **Excluded proposals** — `excludeIds` is always empty in improve (`improve.ts:869`); "this run's fresh proposals are never touched" is an ordering effect, because the pre-pass runs before consolidation mints. `--older-than <days>` on `proposal drain` excludes proposals younger than that, counted from creation or the last reopen, and fails safe by excluding any proposal whose age cannot be computed. (`drain.ts:559-564`; `src/commands/proposal/proposal-cli.ts:468-505`; `src/commands/proposal/proposal-types.ts:316-326`; `improve.ts:869`)
661
+ - **What auto-accept means for git** — Every auto-accept is the ordinary promote path, so it writes the asset and commits it to the target with the subject `akm accept: <source> <8-char id> <ref>`. For a consolidate promotion the commit also carries the archived source memory and its tombstone. Filesystem targets never push; a git-kind target uses the default push behaviour. Commit failure is only a warning, not a failure of the accept. The standalone `drain --promote` asks for confirmation before promoting ("commit to git and cannot be batch-reverted"). A revert is per proposal (`akm proposal revert`). (`src/commands/proposal/repository.ts:1459-1462,1574-1596`; `src/core/write-source.ts:615-627`; `proposal-cli.ts:471-482`)
662
+ - **Drain failures stay pending** — An attempt that fails (validation, promote error) is listed under `failed` with a categorized reason and the proposal stays pending. A rejected proposal is recorded with a gate decision under gate `triage`. (`drain.ts:142-150,641-656`)
663
+
664
+ ## Proposals and feedback
665
+
666
+ Paths are relative to the rules-010 checkout. `repository.ts` is `src/commands/proposal/repository.ts`.
667
+
668
+ - **Statuses** — The stored statuses are `pending`, `accepted`, `rejected` and `reverted`. Only `pending` lives in the live queue; the rest are archived rows. **There is no `expired` status.** An expired proposal is a `rejected` one with an `auto-rejected` gate decision, reason `expired`, gate `retention`. (`src/commands/proposal/proposal-types.ts:72-73,337`; `repository.ts:611-628,896-900`; `src/commands/proposal/proposal.ts:95-110`)
669
+ - **Mint rules** — Minting refuses an invalid ref, an unknown type, empty content, a qualified-ref/target conflict, a cross-bundle rewrite, a canonical-type structure error, and (for `consolidate`) an empty description. Each refusal emits `proposal_creation_rejected`. An unknown `source` only warns. An automated source with no `sourceRun` only warns. Confidence outside [0,1] is dropped. A `.md` payload is rewritten with `ensureAkmMarkdownType` (a missing `type:` and `updated:` are added) for every source. The mint and its `proposed` ledger rows commit in one transaction. *Why:* consolidate historically flooded the queue with frontmatter-less proposals; the unknown-source and missing-`sourceRun` warnings exist so accept-rate-per-source and per-run metrics stay aggregatable. (`repository.ts:350-512,397-407`; `proposal-types.ts:35-70`)
670
+ - **Accept** — Accepting is idempotent if the published bytes are still there. It requires `pending`, a valid proposal (the validators repair then check content), a resolved bound write target, and a writable akm asset. It stamps provenance and carries the live target's bookkeeping frontmatter forward. Lint findings (`unquoted-colon`, `missing-ref`, `stale-path`) only warn. The write is atomic (temp file plus rename). If it overwrites an existing asset, the prior content is kept as `backupContent`. It then archives the proposal as `accepted`, writes the ledger row and the `promoted` event in one transaction, indexes the file, and commits. (`repository.ts:1290-1303,1353-1397,1532-1597,939-1056`)
671
+ - **beforeHash conflict check** — A proposal records `beforeHash` and `beforeHashNormalized` (bookkeeping frontmatter removed) of the target at mint. Accept refuses with "target changed after proposal … refusing to overwrite newer content" unless the live target still matches. If the target already holds this proposal's content, finishing is allowed. A proposal with no `beforeHash` that was a create refuses when the target now exists. Reopen applies the same freshness check. *Why:* STALE/R20 — a same-run bookkeeping rewrite must not stale a proposal while a real edit still does, and accept never overwrites newer content. (`repository.ts:1399-1433,489-491,2268-2291`)
672
+ - **Reject** — `akm proposal reject` requires `--reason` and asks for confirmation. Only a pending proposal can be rejected; it is archived as `rejected` with the review reason and a `rejected` event. The ledger outcome is `rejected`, or `expired` / `failed` when the gate reason is expiry, stale target or asset missing. Bulk reject works by `--generator` with `--max-diff-lines` and `--older-than` filters. (`proposal-cli.ts:205-299`; `repository.ts:715-727,751-790,1058-1091`)
673
+ - **Bulk accept** — Bulk accept needs `--generator` (and no id) and a confirmation. `--older-than` is in days and counts from creation or the last reopen. `--max-diff-lines` counts a retire proposal's target file lines, since its own payload is empty. A retire proposal with `continuityRisk` is never bulk-accepted, only by id; the skipped count is reported. Bulk reject is unaffected. (`src/commands/proposal/proposal.ts:454-559`; `proposal-cli.ts:116-203`)
674
+ - **Revert** — Only an `accepted` proposal can be reverted. It needs a `backupContent` (a new-asset proposal has none, so use direct deletion instead). The asset must still have exactly the content recorded at accept, or revert refuses to clobber newer content. Reverting twice is a no-op. The prior bytes are restored, a `reverted` row and a `proposal_reverted` event are written, and the file is re-indexed. A retire revert restores the archived files byte-exactly and removes the tombstone only after the decision is recorded. On a git-backed bundle the purge sweep deletes archived files 30 days after retirement once they are tracked and clean; revert then rewrites the primary from the proposal's `backupContent` (the exact pre-retire bytes) when its archived copy is gone and the original path is free, and `cleanup.md` is never purged. A purged `.derived` twin has no recorded bytes, so revert still refuses with "archived copy … is missing" for it; git has those bytes (fixed in this PR, #11). (`repository.ts:1959-1997,2068-2084,2109-2115,2135-2137,2188-2242`; `memory-improve.ts:764-860`; `proposal.ts:413-450`)
675
+ - **Reopen (undo a rejection)** — Only a `rejected` proposal that still has its change envelope and an unchanged target can go back to `pending`. A retire proposal needs its successor and both recorded hashes to still match, and only one pending retire proposal may involve any given asset. It is all-or-nothing and takes full ids. The rejection and its gate verdict move into `reviewHistory`. A stale `staged` or `auto-rejected` verdict is cleared, and a `deferred` one is kept so the drain keeps leaving it to a person (only a deferral from a non-`triage` gate; a drain-stamped one is re-triaged). The ledger row is reset. A reopen also clears a retention `auto-rejected` verdict, so it can restore an expired or rejected retire proposal whose files and hashes are still fresh. A reopened proposal's wait (for `--older-than` and expiry) restarts at the reopen. *Why:* a rejection is otherwise final, and a person's rejection suppresses the pair pass from re-proposing that retirement (an expired or stale-target one does not). A reopened proposal is therefore never one the next accept would refuse as stale. (`repository.ts:2261-2431`; `proposal-types.ts:93-109,316-326`; #997)
676
+ - **Expiration pass** — Pending proposals older than `archiveRetentionDays` (config default 90; 0 disables) are archived as `rejected` with gate `retention`, reason `expired`, and a `proposal_expired` event. The wait is counted from the last reopen. Retire proposals expire like every other kind (fixed in this PR, #10). The ledger records `expired`, a 1-day grace and not the rejection window, since nobody judged the content. The pass runs inside improve's proposal hygiene step. *Why:* an unreviewed proposal should not wait forever, and `akm proposal reopen` (#997) can restore an expired retire proposal while its files and hashes are still fresh. (`repository.ts:872-927`; `src/storage/repositories/improve-ledger-repository.ts:79-80,151-153`; `src/commands/improve/loop-stages.ts:654-720`)
677
+ - **Orphan purge** — Only pending `reflect` proposals whose target no longer exists in any configured source dir are auto-rejected, with gate `orphan-purge`, reason `asset-missing` and review reason "Asset no longer exists on disk". Lessons and other sources are skipped. *Why:* other sources, lessons above all, may legitimately target assets that do not exist yet. (`repository.ts:827-869`; `loop-stages.ts:671-696`)
678
+ - **Procedural rejections are not content verdicts** — Rejections from stale target, expiry and orphan purge (and old rejections whose review reason starts `expired:`) are excluded from the "previously rejected" prompt context and the accept-rate metric. (`proposal-types.ts:340-357`)
679
+ - **Rejected-proposal context for the model** — A stage is shown at most 3 (`MAX_REJECTED_PROPOSALS`) of the newest non-procedural rejections for the same ref, each with its reason and the first 500 characters of the rejected content, as "don't repeat this". It reads the archive. (`src/commands/improve/stage.ts:208-231`)
680
+ - **Gate decisions** — A gate stamps `staged`, `deferred`, `auto-accepted` or `auto-rejected` on a pending proposal without changing its status. `staged` means a judge passed this exact content hash. `deferred` writes a `review_needed` ledger row, and a proposal no longer pending is silently skipped. `akm feedback` stamps `auto-accepted` with reason `manual-fix` (gate `feedback`) on an exact fix it then accepts at once (changed in this PR, decision 3); reflect and distill stamp `deferred` with gate `quality-gate` for `judge-error`, `judge-uncertain` and `quality-review` (changed in this PR, decision 4), and the drain leaves a `deferred` proposal from a gate other than its own to a person (`drain.ts:586-590`). (`repository.ts:792-825`; `proposal-types.ts:111-151`)
681
+ - **`akm feedback` basics** — Exactly one of `--positive` or `--negative` is required. The signal is recorded as a usage event. For a user-attributed event the utility is recomputed right away with no reindex, by the same function and formula `akm index` uses (see Utility update). When a previously high-utility asset (0.5 or more) drops below 0.5, an `improve_review_needed` event is emitted (an event, not a proposal, so it cannot overwrite an asset); no code consumes it. The output's `rankingUpdate` field reports the ranking effect, not the utility write: `applied: true` exactly when the recorded event is user-attributed (`source='user'`), which `akm search` and `akm curate` count themselves, so the next search already reflects the signal (see Search: Feedback scales the fused score; changed in this PR, decision 1); a failed utility write only warns and does not change it. The utility score is read by improve and metrics only. (`src/commands/feedback-cli.ts:173-240,586-592,834-854,897-899`; #386)
682
+ - **Anti-self-reinforcement guard** — A feedback event whose usage-event source is not `user` (`improve`, `task`, `audit`, `unknown`) is recorded but does not count toward search ranking, and the utility is not recomputed for it. The output says so as `rankingUpdate: { applied: false, reason }`, the reason naming the source and saying it does not count toward search ranking. Set `AKM_EVENT_SOURCE=user` to record user demand. *Why:* it stops an agent or automated pipeline from boosting its own picks, and the search ranking and the utility recompute both apply the same rule by counting only `source = 'user'` events (`usage-events.ts:122-148`, `indexer.ts:1880`); the code says "must not be removed" (R-034 only asks that the skip not pass silently). (`feedback-cli.ts:224-229,897-899`; tests `tests/integration/feedback-command.test.ts`, "rankingUpdate")
683
+ - **Negative feedback requires `--reason`** — By default a negative with no reason is a `UsageError` (exit 2), telling you to say what is wrong and what should change. `feedback.requireReason: false` in akm.json downgrades this to a warning. The reason is shown to the next improve run, which may repair the asset's description, title or `when_to_use` from it, but not its text. *Why:* F-3 / #384. (`feedback-cli.ts:643-658`; `feedback-cli.ts:494-507`)
684
+ - **Exact fix: `--replace`/`--with`/`--source` (changed in this PR, decision 3)** — The flags are repeatable. They are negative-only, each `--replace` needs exactly one `--with`, and `--source` (a URL, command or file showing the correct fact) and `--reason` are both required. Each `--replace` text must appear exactly once in the file as it stands after the earlier replacements, with an error naming the line numbers if it appears multiple times. A fix must change something and the frontmatter must still parse as a mapping. The fix is checked before anything is recorded, so a failed fix leaves no trace. A fix is refused if a proposal could not write the asset's actual file (for example a git bundle's nested `tasks/README.md`). *Why:* otherwise accepting would create a second file and leave the real one unfixed. A checked fix is applied at once, with no review, gate or judgement: `akm feedback` mints a `feedback` proposal for the audit trail, `akm proposal revert` and reflect's "already acted on" rule, stamps it `auto-accepted` with reason `manual-fix` (gate `feedback`), and accepts it in the same command through `akmProposalAccept`, the ordinary accept (the same checks and the normal git commit). If that accept refuses, the proposal is rejected (`manual fix not applied: <error>`), the file is left alone and the command fails with the accept's error. A mint refusal after the usage and `feedback` events were written leaves both with `metadata.fix` and no proposal. The person who typed the fix no longer has to run `akm proposal accept`, and the proposal can no longer expire unreviewed; the drain and the judgment tier never see it. *Why:* a person typed it, so a second machine-level gate on a human-typed action was wrong (Policy decision 3). (`feedback-cli.ts:271-347,594-641,737-776,779-824`; `repository.ts:872-927`)
685
+ - **`--superseded-by` / `--outdated`** — They are negative-only and need `--reason` and `--source`. The two are mutually exclusive, `--superseded-by` takes one ref, and they apply to markdown assets only (not `env` or `secret`). `--superseded-by <ref>` requires the ref to be indexed and not the asset itself. The proposed edit sets `beliefState: superseded` and adds the ref to `supersededBy`, leaving `contradicted` and `archived` in place. `--outdated` sets `beliefState: deprecated`, leaving `superseded`, `contradicted` and `archived`. Either may be combined with `--replace`/`--with`. Like an exact fix, the edit is applied at once (see Exact fix). (`feedback-cli.ts:351-486,601-626,748-765`)
686
+ - **Positive feedback** — `--positive` records that the asset helped and moves its utility score, which only improve and metrics read; it also raises the asset's `akm search` and `akm curate` score (see Search: Feedback scales the fused score; changed in this PR, decision 1), as the CLI help, the hint files and `docs/reference/cli.md:1768-1776` say. It does not trigger a rewrite and takes none of the fix flags. With `--applied-to` on a lesson target it appends the feedback ref to the lesson's `lessonStrength`. Non-lesson targets get a loud warning, not a silent no-op. (`feedback-cli.ts:522-525,604-609,856-888`)
687
+ - **Feedback records what text it judged** — Each feedback event stores the asset's body hash at the time, so reflect can tell feedback given on an earlier version of the text. Env and secret files are never read. (`feedback-cli.ts:242-258,735-736`)
688
+ - **Remember: hot capture** — `akm remember` always writes a memory with `captureMode: hot` and `beliefState: asserted`. If you pass no description, it synthesizes one deterministically from the body: whole sentences up to the description cap, with a leading heading skipped and no LLM call. *Why:* #834 — hot-path memories had no description or tags and the indexer covers only frontmatter and headings, so they were retrievable only by their filename. The ranking rationale ("user-supplied memories outrank otherwise-equal background-derived ones") is stale: no ranking code reads `captureMode`, and since decision 5 (changed in this PR) neither does consolidate. (`src/commands/read/remember-cli.ts:210-246,310-326`; `src/commands/remember.ts:184-214`)
689
+ - **Remember: validation and tags** — `--name` must be flat (use `--path` for a subdirectory). `--tag`, `--expires`, `--source` or `--description` require at least one tag, so `--description` alone refuses the write while the zero-flag path writes with no tags and a synthesized description; the suggested remedies are unreliable (`--auto` derives only `code` from a fenced block, `--enrich` is fail-soft to no tags), which works against #834's goal of findable hot memories. `--show-similar` runs a search that logs events under the ambient event source (`remember-cli.ts:36-50`). `--auto` or `--enrich` alone, scope alone and `--xref` alone do not require tags. `--expires` is a shorthand duration resolved to an ISO date (`30d`, `12h`, `5m` minutes, `3M` months). Each `--xref` must resolve in the write target or a configured source, and an unresolvable one aborts with nothing written. (`remember-cli.ts:36-50,163-208,261-308`; `src/commands/remember.ts:75-87`)
690
+ - **Remember: `--supersedes`** — The ref must resolve; an unresolvable or self-referencing one aborts with no partial write. The new memory gets an xref to the old asset and the old asset is demoted (`beliefState: superseded` plus `supersededBy`, a metadata-only edit), so ranking prefers the correction. A ref outside the write target still writes the correction but skips the demotion. `--force` is needed to overwrite an existing name. (`remember-cli.ts:82-86,108-112,175-189`)
691
+ - **Remember: `--enrich`** — The LLM call has a 10 s timeout and is fail-soft: a provider, timeout or parse failure warns and writes the memory without enrichment. An invalid config is a hard pre-write error. Heuristic `--auto` derives `code`, `subjective`, source and `observed_at` from the body with no network. The result gets no protection from consolidation: a `captureMode: hot` memory is retired, promoted and archived like any other (changed in this PR, decision 5). *Why:* the write path must not block on a misbehaving endpoint. (`src/commands/remember.ts:230-253,297-306,381-385`)
692
+
693
+ ### Code and doc mismatches
694
+ - The task mentions "Merge/delete operations" and a "confidence cutoff for staging". Neither exists in this build. The promote pass is promote-only (see the first rule). Staging has no confidence threshold (see the gate-staging rule). A leftover health-check message mentions a "confidence threshold" (`src/commands/health/checks.ts:1533`), which is legacy wording.
695
+ - `docs/plans/0.10-improve-defaults.md` and CHANGELOG #1131 are the evidence for removing `antiCollapse`, `p90ChunkSecondsDefault` and `minPoolSize`.
696
+
697
+ ## Index
698
+
699
+ - **Incremental vs full are the same write path** — A run is "incremental" only when not `--full`, the stored primary `stashDir` equals the current one, and `builtAt` exists; otherwise "full". Both drain directories through the same diff-persist path: `entries.id` is preserved on conflict, so embeddings, utility scores and usage links stay attached to unchanged rows, and nothing is wiped and rebuilt. *Why:* header comment on index consistency. (`src/indexer/indexer.ts:20-38,686`)
700
+ - **Per-directory stat fingerprint gate** — A directory is skipped before any file is read when its fingerprint matches the persisted `index_dir_state` row and that row recorded rows greater than 0. The fingerprint is sha256 over each file's `(basename, size, mtimeNs, ctimeNs)`, sorted, plus the variant `<adapter.id>@<adapter.version>`. Size and ctime are used because mtime alone is writable by `touch -r`, `rsync -t` and `cp -p`. ctime also moves on chmod/copy, which costs an extra rescan but never serves stale content. *Why:* #900, where a no-op incremental index cost about 19-21 minutes of CPU on 23,865 entries; the ctime reasoning is in the code comment. (`src/indexer/passes/dir-staleness.ts:119,147`, `src/indexer/indexer.ts:1228-1235`, #900)
701
+ - **Cases that always re-drain** — A directory re-drains every run when it is in an ownership handoff or holds a symlinked workflow source. It also re-drains when it lost rows to same-ref dedup (its `row_count` stays NULL), and when a cached `deduped-zero-row` directory follows changed directories. *Why:* such a directory depends on directories persisted before it, not only on its own files. (`src/indexer/indexer.ts:1215,1493-1500`, `src/indexer/passes/dir-staleness.ts:138-145`)
702
+ - **Per-file cursor inside a drained directory** — On an incremental run, a sibling file with an unchanged content hash, path and adapter, under the same adapter variant, is not re-upserted. `--full` re-persists every entry. *Why:* a directory is drained whole, but the unchanged rows are already stored exactly as the drain would write them, metadata layered onto the row included. (`src/indexer/indexer.ts:1458-1467`)
703
+ - **Incomplete scan keeps last-known-good rows** — If any source walk is incomplete (unreadable directory, unresolved source, vanished file), its rows are preserved with a warning. Removed-source purge and `--clean` are skipped, and `builtAt`, `stashDirs` and `sourceOwners` are not advanced. The response carries `scanComplete: false`. *Why:* advancing the watermark could make a recovered source look unchanged. (`src/indexer/indexer.ts:379-387,744,759,1188`)
704
+ - **Removed or moved sources are purged** — A bundle in the stored `sourceOwners` that is no longer configured has its entries deleted once the walk completes (a source whose root moved but whose bundle id is still configured is not purged by that path). A full run also deletes any indexed bundle that is not currently configured. Why: not stated in code/docs beyond the mechanics. (`src/indexer/indexer.ts:271-300`)
705
+ - **One owner per physical directory** — The first configured source exposing a directory owns it. Later duplicates are skipped (`duplicate-dir`), and a prior owner's rows are removed when ownership changes, but only if every source scanned completely. (`src/indexer/indexer.ts:1105-1115,1167`)
706
+ - **Same-ref collision is deterministic** — When two files in one source map to the same `item_ref`, the file with the smaller path (code-point order) wins. The warning reads `Two files claim <ref>: indexed A, skipped B.` *Why:* #1050, where the winner depended on filesystem listing order and the first incremental index swapped it and re-embedded. (`src/indexer/indexer.ts:1432-1455`, #1050)
707
+ - **One adapter per bundle component** — `components.<name>.adapter` wins if set. Otherwise a fixed ordered probe runs: website-snapshot, agent-skills, claude, opencode, dotenv, akm-workflow, akm-task, llm-wiki, akm, okf; `generic-files` is explicit-only. If nothing claims the root, the fallback is `akm`. An adapter id not in the registry makes that component skipped with `Skipping component "<id>": unknown adapter id`. Config load rejects an unrecognized `components.*.adapter` with the list of valid ids. *Why:* #909, where typos silently fell back to `akm`. (`src/core/adapter/adapter-ids.ts:22`, `src/core/adapter/detect-adapter.ts:90`, `src/indexer/indexer.ts:1193-1198`, `src/core/config/schema/sources-bundles.ts:223`, #909)
708
+ - **Mixed layouts detect as `akm`** — If the probe picks `agent-skills`, `claude` or `opencode` but the root also holds a top-level directory with real files that the `akm` adapter recognizes, the result is `akm`. *Why:* #908, where auto-detection silently dropped 73 documents from one bundle. (`src/core/adapter/detect-adapter.ts:29,90`, #908)
709
+ - **"<adapter> adapter skipped N files in <dir>/" warning** — This fires once per process, when any non-`akm` adapter, explicitly configured or auto-detected (`claude`, `opencode`, `agent-skills` etc.), recognizes nothing in a top-level directory where the `akm` adapter would index files. It reads `…skipped N file(s) in tools/ — set components.<name>.adapter to "akm" to index them`. *Why:* #908, since detection cannot see an explicit config. (`src/indexer/indexer.ts:953-1000`, #908)
710
+ - **Auto-detected adapter is persisted to config.json** — `akm index` writes `bundles.<id>.components.<name>.adapter` for sources that declared none. It reports `configUpdated.detectedAdapters` in the result and prints a stderr notice, except on implicit (read-triggered) runs. `bundle update` does not persist. *Why:* R-056, so a config write is not invisible. (`src/indexer/indexer.ts:553-616,664-669`)
711
+ - **Walk hygiene** — Inside a git repo the walk uses `git ls-files --cached --others --exclude-standard`; otherwise it walks manually. It skips `.git`, `node_modules`, `bin`, `.cache`, dot-directories, `.stash.json`, `.gitignore` and `.gitattributes`. Symlinks are not followed, except owned workflow-file symlinks for the `akm` and `akm-workflow` adapters. The `okf` adapter sees all directories except `.git`. *Why:* lstat, not stat, so a tracked symlink never causes a read outside the root. (`src/indexer/walk/walker.ts:20-21,107-160`, `src/indexer/indexer.ts:1026-1031`)
712
+ - **Adapter-owned abstention; sensitive files never read** — The `akm` adapter returns null, by path or stat only, for env files with a `.sensitive` sibling, `secrets/*.sensitive` and `*.lock`, secrets with a `.sensitive` sibling, a bundle-root `README.md`, and wiki `schema.md`/`index.md`/`log.md`. env and session types get no body or fragment projection. *Why:* bytes-never-read invariant for sensitive files. (`src/core/adapter/adapters/akm-adapter.ts:128-175`, `src/indexer/passes/metadata.ts:1215-1220`)
713
+ - **Indexed body is capped at 1,000,000 chars** — Truncation always warns (`indexed content truncated to 1000000 chars`) and sets `contentTruncated`. *Why:* the old 16,384 cap silently dropped the tails of long docs from both FTS and embeddings; the new cap exists only to stop pathological files. (`src/indexer/passes/metadata.ts:765-778,1229`)
714
+ - **Broken workflows are dropped with a warning** — A workflow that fails the shared compiler is omitted and warned `Skipped workflow <path>:`. At default verbosity only one summary line is printed (`N workflow spec(s) skipped…`), and `--verbose` shows details. *Why:* #273, noise on cold-start search against fresh registry clones. (`src/indexer/scan/drain-dir.ts:75`, `src/indexer/indexer.ts:724-737`, `src/indexer/passes/metadata.ts:1307-1340`)
715
+ - **`.yml` workflows are no longer workflows** — The GitHub-shaped YAML format is removed. A `.yml` under `workflows/` fails compile with `Workflow source <path> must use .md.` and is skipped like any broken workflow. Only `.md` is a workflow source. *Why:* #1096/#1165, unused by the owner's bundle or OpenPalm. (`src/workflows/compile.ts:41-47`, CHANGELOG 0.10.26101001 "Removed", #1096, #1165)
716
+ - **Zero-row and empty-generated-set notices** — A directory with `.md` or script-extension files that persists 0 rows warns `[index] zero-row <dir>: <reason>`. Other zero-row directories log only under `--verbose`, as does `deduped-zero-row`. Reasons are `deduped-zero-row`, `workflow-noise` (a `Skipped workflow` warning mentions the directory), `empty-generated-set` (the adapter recognized nothing), or `zero-row:<previous reason kind>`. (`src/indexer/indexer.ts:1498-1514`, `src/indexer/passes/dir-staleness.ts:223-237`)
717
+ - **Embeddings are off by default** — `semanticSearchMode` defaults to `off`: no vectors, and the index reports "Keyword index ready. Semantic search is disabled." With `auto`, the provider is remote when `embedding.endpoint` is http(s), else the built-in local model `Xenova/bge-small-en-v1.5` (override with `embedding.localModel`). Search ranking uses an exact in-process cosine scan, with no ANN index. (`src/core/config/config-schema.ts:136`, `src/core/config/config.ts:76-81`, `src/indexer/indexer.ts:1539-1544,1575-1590`, `src/llm/embedders/local.ts:24`, `src/storage/repositories/index-vec-repository.ts:122`)
718
+ - **Embedding fingerprint decides re-embedding** — A stored vector carries `remote:<model>|<dimension>` plus the document template, or `local:<model>`. The endpoint is deliberately not part of it, so moving the same model to another host does not re-embed. A fingerprint change re-embeds incrementally: old vectors stay until each entry is replaced, and readers use only current-model rows. `akm index --reembed` is the only purge. *Why:* #955/#956, so an interrupted pass resumes instead of restarting from zero. (`src/indexer/materialize-embeddings.ts:55-71,175-195`)
719
+ - **Embedding request limits and breaker** — Defaults are a 512-token per-document head cap (`embedding.maxInputTokens`, about 4 chars per token), a 6000-token per-request budget, a 120,000 ms request timeout, and concurrency 1 for loopback and 2 for remote (override 1-16). Each provider batch commits on its own, with a 15 s heartbeat. After 3 consecutive failures the pass stops and returns failure, keeping committed batches. A document is skipped only if its capped head is empty. *Why:* #954/#956, where a dead endpoint ground through every batch for hours. (`src/indexer/materialize-embeddings.ts:113,173,268`, `src/llm/embedders/remote.ts:48,63,96,331`, `src/core/config/schema/embedding.ts`)
720
+ - **Index verification** — After embeddings, `verification.ok` is false when stored embeddings are fewer than embeddable entries (`semanticStatus: blocked`, with remote or local remediation text). With 0 entries the result is "Index ready. No assets were found yet." (`src/indexer/indexer.ts:1553-1600`)
721
+ - **Optional, non-blocking rebuild lock** — `akm index` takes a PID-liveness lock. With `--skip-if-locked` and the lock held, it exits 0 reporting `skipped: lock-held`. Without the flag it warns and contends. There is no age-based reclaim. `indexWrittenAssets` (the write path for `remember`, `import` and extract) skips while the lock is held, uses a 5,000 ms busy timeout, and fails open. *Why:* #872 (an age-based lease stranded an install in a half-day indexing outage, per `run-lock.ts:15`) and #956. (`src/indexer/index-rebuild-lock.ts:53`, `src/commands/sources/stash-cli.ts:90-112`, `src/indexer/index-written-assets.ts:47,86`, #872, #956)
722
+ - **Reads only bootstrap the index** — `ensureIndex` rebuilds inline only when the DB is missing, has no entries table, has 0 rows, or was built for a different stash. It does not check content freshness, and the read path never clones, pulls or fetches sources (`hydrateSources: false`). `mode: "blocking"` (used by improve) also rebuilds when an indexable file under the primary stash's akm placement roots (symlinks and dot-directories skipped) is newer than `builtAt` with a differing hash, or unindexed; other bundles and non-akm layouts never trigger it. So a hand-edited file is visible to search only after a reindex: reads never check freshness, `core/index-refresh` runs daily, and the only sub-daily reindex is that blocking `ensureIndex` in the improve consolidate task. *Why:* the old stale-walk plus detached reindex per read caused writer contention. (`src/indexer/ensure-index.ts:11-22,173-222,227-247`)
723
+ - **Finalize pass** — After embeddings the indexer rebuilds FTS BM25 totals if stale, relinks `usage_events` to entry ids, recomputes `utility_scores`, clears stale LLM-cache rows, purges usage events older than 90 days, and VACUUMs after a layout migration or when more than half the pages are free. *Why:* #1048, where stale totals made an updated index rank differently from a fresh one. (`src/indexer/indexer.ts:339-420,1839-1849`, #1048)
724
+ - **Utility has one formula and one implementation, and search reads neither (changed in this PR)** — `recomputeUtilityScores` is the only writer: `utility = prev × 0.7^days + max(selectRate, max(0, pos − neg) / total) × (1 − 0.7^days)` with `prev ?? 0`, counting only `source='user'` events. `akm index` finalize runs it for every entry on every run (and purges usage events past 90 days, and moves the `last_utility_computed_at` watermark); `akm feedback` runs it for the one entry it touched, without the purge or the watermark (see Proposals and feedback: Utility update). The value is a decayed base converging to a positive-only target that can never fall below the show/search rate; one positive plus one negative is rate 0. `akm search` never reads `utility_scores`; its readers are improve, metrics and this recompute, so search ranking, which takes its feedback signal from the usage events (see Search: Feedback scales the fused score), does not depend on it. (`src/indexer/indexer.ts:362-367,1839-1950`, `src/commands/feedback-cli.ts:232`)
725
+ - **No LLM work in `akm index`** — The metadata-enhance and LLM entity-graph passes are retired (0.9.17-alpha.9), and `timing.llmMs` stays at about 0. Declared links (`asset_links`, #935: `xrefs`, `supersededBy`, `derived_from`, workflow/task `uses` and similar) are derived from the stored document with no model. The memory-inference pass lives under `src/indexer/passes` but is driven from improve (`loop-stages.ts`), and is gated by `experimental.improveAutonomy`. *Why:* metadata enrichment moved nDCG@10 by -0.0092 at about 27 B70-hours cost (CHANGELOG 0.9.17). (`src/indexer/indexer.ts:739-742`, `src/indexer/links/declared-links.ts:1-30`, `src/commands/improve/loop-stages.ts:517-565`, `CHANGELOG.md:2027`, #935)
726
+ - **`--clean` and `--dry-run`** — `--dry-run` is only valid together with `--clean`, otherwise a `INVALID_FLAG_VALUE` error. `--clean` removes rows whose file is absent but keeps rows for unreadable files, with a warning. *Why:* #791, where EACCES was treated as absent and wiped an index. (`src/indexer/indexer.ts:429-470,607-620`, #791)
727
+ - **Derived memories** — A `*.derived` memory carries a `derived_from` link to its parent. Identical-content copies are not separate search results (see Search). Why: not stated beyond the link mechanics. (`src/indexer/links/declared-links.ts:23-35`)
728
+
729
+ ### Code and doc mismatches
730
+ - `dir-staleness.ts:15,58` still describe the fingerprint as "basename set + max mtime" / `(mtimeMs, ctimeMs)`; the code hashes `(basename, size, mtimeNs, ctimeNs)` (`dir-staleness.ts:119,147`).
731
+
732
+ ## Search and curate
733
+
734
+ - **Ranking is RRF, then one feedback factor (changed in this PR, decision 1)** — Two channels of 100 candidates each (whole-document BM25 and nearest document vectors) are fused with weight 1 per channel and `1/(60+rank)`. Ties break on `item_ref`, so the max fused score is about 0.033. The fused score is then multiplied by the asset's feedback factor (see Feedback scales the fused score) and the list is reordered with the same tie-break, and `score` is that product. Filters narrow the list and never reorder it. *Why:* on the retrieval suite RRF scored nDCG@10 0.568 against 0.349 for the boost stack it replaced; the feedback factor is one log-count term and does not bring the boost stack back. (`src/indexer/search/ranking.ts:5-15,39,83-93`, `src/indexer/search/db-search.ts:76,336-338`, CHANGELOG 0.9.17 "Search and curate are rebuilt")
735
+ - **Lexical channel** — BM25 over an OR of the query's tokens: NFKC, lowercased, Unicode letters and numbers, deduplicated, English stopwords removed (an all-stopword query keeps them all). All columns weigh 1.0, the tokenizer is `porter unicode61`, and there is no prefix matching (`dock` does not match `docker`). *Why:* requiring every word cost 0.108 nDCG@10. (`src/indexer/search/fts-query.ts:35-42`, `src/storage/repositories/index-fts-repository.ts:132-168`, `src/storage/repositories/index-entry-schema.ts:92`)
736
+ - **Vector channel and fallback** — The query is embedded with the model's query template. If `semanticSearchMode` is `off`, or the index has no embeddings, search is keyword-only. If the embedder is slower than `embedding.queryTimeoutMs` (default 3000) or fails, search falls back to keyword with the warning `Vector search unavailable: …`. `searchMode` is `semantic`, `keyword` or `fts-fallback`. *Why:* a slow embedder must not block search. (`src/indexer/search/db-search.ts:79,641-677,360`)
737
+ - **Limits** — `--limit` defaults to 20 and is capped at 200. Curate defaults to 4. Search returns 100 candidates per channel before filtering. Only `--type` and `defaultExcludeTypes` reach the lexical SQL; the vector channel applies them after its 100-nearest cut, and `--from <bundle>`, `--filter` scope, proposed-quality exclusion and `--belief` run in `applyEntryFilters` after fusion over that pool of at most 200 ids. With a narrow filter fewer than `--limit` hits can come back although more matching assets exist (browse and enumeration are the exception). (`src/commands/read/search.ts:43,459`, `src/commands/read/curate.ts:129`, `src/indexer/search/db-search.ts:76,335-339,554-581,664`, `src/storage/repositories/index-fts-repository.ts:153-169`)
738
+ - **Filter chain and defaults** — The order is source narrowing (`--from <bundle>`), scope (`--filter user|agent|run|channel=`, AND-joined), proposed-quality exclusion (`--include-proposed` opts in), derived-twin belief inheritance, then `--belief all|current|historical`. The untyped search excludes `session` assets (`search.defaultExcludeTypes` default `["session"]`, `[]` disables, `--include-sessions` or an explicit `--type` bypasses). `--type` is free-form exact match, and an unknown type returns no hits. *Why:* #627, sessions polluted default results. (`src/indexer/search/db-search.ts:284,534-582`, `src/commands/read/search-cli.ts`, #627)
739
+ - **Derived twins inherit demotions** — At search time, a `.derived` memory with no belief state of its own inherits its base's demoting state (`contradicted`, `superseded`, `deprecated`, `archived`). It is never promoted, and nothing is written to disk. *Why:* the belief resolver would reset a frontmatter write to `active` on the next improve run. (`src/indexer/search/db-search.ts:592-622`)
740
+ - **Identical content collapses** — Hits are deduplicated by file path, then by whitespace-normalized indexed content, keeping the higher-ranked copy. This covers a memory also promoted verbatim to knowledge; the content dedupe exists only on the scored path, and browse dedupes by path only. (`src/indexer/search/db-search.ts:395-422,521,803-811`, CHANGELOG 0.9.17)
741
+ - **Browse queries** — An empty query or one with no searchable token enumerates entries, sorted by type, name, then path, with fixed score 1. A conceptId-prefix query (`memories/projecta/`, `bundle//`, `bundle//skills/`) also enumerates. The trailing `/` is required, matching is case-insensitive and exact on `/` boundaries, and `--type` wins over the prefix. Prefix queries ignore `defaultExcludeTypes`. (`src/indexer/search/fts-query.ts:46-87`, `src/indexer/search/db-search.ts:273-312,470-517`)
742
+ - **Search hints and warnings** — An index older than 7 days adds `Search index was last built N day(s) ago… run 'akm index'`. Multi-bundle concept collisions add a warning. `semanticSearchMode: auto` warns only for `embedding.endpoint` set without `embedding.model`, the one config that can never succeed, and the warning names the remedies (set the model, remove the endpoint to use the built-in local model, or switch semantic search off). With no `embedding` block `auto` falls back to the built-in `Xenova/bge-small-en-v1.5` and does not warn (fixed in this PR, #13). (`src/indexer/search/db-search.ts:73,128-143,196-203`, `src/llm/embedder.ts:152-159`, `src/llm/embedders/local.ts:24`, `src/commands/read/search.ts:45-68`)
743
+ - **Sources** — `--from local|registry|all|<bundle name>`. Registry hits are a separate `registryHits` list and are never ranked into local results. A registry-only search needs no local stash. An unknown bundle name gives `INVALID_SOURCE_VALUE`. (`src/commands/read/search.ts:155-165,261-300,451`)
744
+ - **`--detail` levels** — Values are `brief|normal|full|agent`, and the default is `brief` from `output.detail`. `agent` is a projection, not a verbosity. Brief search hits keep `type, name, ref, action, estimatedTokens, keys`. Normal is `type, name, description (capped at 250 chars), action, score, estimatedTokens, warnings, quality` (and `keys`): it drops `ref`. Full adds the envelope and timing. Agent keeps `name, ref, type, path, editable, editHint (only if read-only), description, action, score, estimatedTokens, keys`. `akm show` ignores the config default and returns full unless `--detail brief` is passed explicitly. `--shape` no longer exists (#1091). *Why:* a bare `show` must always return the asset body. (`src/output/context.ts:49,115-128`, `src/output/shapes/helpers.ts:13,253-301,340-401`, `src/commands/read/search-cli.ts:155-182,246-262`, `tests/output-shapes-unit.test.ts:131-144`, #1091)
745
+ - **Curate = search + enrichment** — Curate takes the top `limit` of the same fused search, feedback factor included (it calls `akmSearch`, which calls `searchLocal`). Each hit gets a preview (160 chars), run and parameters, and up to 2 `supportRefs` from declared links (outgoing first, then incoming). Registry hits fill only the slots left unfilled. Input that is a harness envelope (starts with `<`, contains `</`) or the stash README line yields an empty abstention instead of unrelated items. `--pack N` greedily packs full hit content up to N tokens in rank order, truncating only a single first oversized hit. (`src/commands/read/curate.ts:129-134,203-225,228,255-268,312-345,495-520`, `src/commands/read/search.ts:195`, `src/core/non-task-input.ts`)
746
+ - **Optional curate rerank** — Off unless `search.curateRerank.enabled`. It reorders the top `topN` fused candidates (default 30, max 50) using name, description and 2,000 chars of indexed content. Any failure keeps the fused order. `search.curateRerank` is host-local and stripped from inherited config. *Why:* #951. (`src/commands/read/curate.ts:132-134,457-480`, `src/core/config/schema/search.ts`, #951)
747
+ - **Feedback adjusts utility, and only for user-sourced signals** — `akm feedback` records a `feedback` usage event. For `source=user` it then recomputes that entry's utility with the same `recomputeUtilityScores` formula `akm index` uses (see Proposals and feedback: Utility update), and if a score that was at least 0.5 falls below 0.5 it emits an `improve_review_needed` event. For improve, task or audit sources no recompute runs and the output reports `rankingUpdate: { applied: false, reason }`. This utility score is read only by improve and metrics; the search ranking uses the events (see Feedback scales the fused score). *Why:* anti-self-reinforcement guard; #862 (search impressions must not inflate scores); F-5/#386. (`src/commands/feedback-cli.ts:173-240,834-854`, `src/indexer/indexer.ts:1839-1950`)
748
+ - **Feedback scales the fused score (changed in this PR, decision 1)** — After fusion, `searchLocal` reads the user feedback counts of the fused candidates from state.db in one query (`countFeedbackSignalsByRef`: `feedback` events with `source = 'user'`, positives and negatives per `entry_ref`) and multiplies each score by `1 + 0.03 × (ln(1+pos) − ln(1+neg))` (`FEEDBACK_WEIGHT` 0.03), then reorders. The factor is the same for every asset and logarithmic: about 2%, 3%, 5% and 9% for 1, 2, 5 and 20 positives, so 5 positives lift a hit more than 2 and each further vote counts for less, and the effect reorders close matches without overriding relevance. A negative lowers the score the same way; an asset with no user feedback is unchanged. Feedback recorded by improve, task or audit does not count. Counts come straight from the events, so the next search reflects a new signal with no reindex. `utility_scores` is untouched and not read by search; its readers are still `improve` (eligibility, salience, retrieval counts), metrics and the index recompute. A search never creates state.db: if it cannot read the counts it ranks without feedback and adds the warning "Feedback was not applied to the ranking: <error>". *Why:* the owner decided that positive feedback should apply a consistent, scaling boost and negative feedback a consistent, scaling penalty. 0.03 was measured on the retrieval suite's fused lists: 5 positives lift a hit from rank 10 by a median of 3 places and 20 by 4 to 5, and at 0.05 two wrong votes cost about 1.6 times the nDCG@10 they cost at 0.03. (`src/indexer/search/ranking.ts:56-93`; `src/indexer/search/db-search.ts:336-338,364-385`; `src/indexer/usage/usage-events.ts:122-148`; consumers grep: `src/commands/improve/eligibility.ts`, `salience.ts`, `preparation.ts`, `src/commands/metrics/*`)
749
+ - **Negative feedback needs a reason** — `--reason` is required unless `feedback.requireReason: false`, which downgrades to a warning. `--replace/--with/--source`, `--superseded-by` and `--outdated` are negative-only fixes that need `--source` and `--reason`, and each `--replace` text must appear exactly once. A fix is applied at once (changed in this PR, decision 3). `--applied-to lessons/<n>` credits a lesson on positive feedback only. (`src/commands/feedback-cli.ts:557-688,742-768,873-889`)
750
+ - **Usage events recorded** — `search` logs one `search` event per returned hit (first 50 resolved hits) plus one summary event carrying `resultCount`, `mode`, and timings. `curate` logs per-item `curate` events plus a summary. `show` logs one `show` event per successful show, and also `events.jsonl` `show`/`select` traces. `source` comes from `AKM_EVENT_SOURCE` (`user` by default; `improve`, `task`, `audit`, `unknown`; an invalid value becomes `unknown`). Queries are credential-redacted, telemetry uses a short busy timeout and is skipped under contention, and `skipLogging` suppresses it. Curate's nested search and show do not log. (`src/commands/read/search.ts:350-440`, `src/commands/read/curate.ts:140-205`, `src/indexer/usage/show-usage.ts:89-111`, `src/indexer/usage/usage-events.ts:41-49`)
751
+ - **Improve-internal reads and the event source** — The continuity check (`searchLocal`, no logging) and distill's related search (`skipLogging: true, eventSource: "improve"`) are clean. But an agent-engine judge's `akm_show` reads are dispatched with no `eventSource`, so the child inherits the ambient `AKM_EVENT_SOURCE` (`task` under the scheduler; unset, which means `user`, for a human-typed `akm improve`), and only reflect's writer run is stamped `improve`; such reads could count as user demand. Whether the opencode plugin's `akm_show` shells out to the CLI is outside this repository, so this is conditional. *Why:* not stated. (`stage.ts:453-455`, `src/llm/structured-call.ts:273-278`, `src/integrations/agent/runner-dispatch.ts:272-274`, `loop-stages.ts:227`, `distill.ts:1364`)
752
+ - **What counts as retrieval for the 90-day rule** — Usage events are purged after 90 days at each `akm index`. Improve's retrieval scope is the set of `entry_ref`s from `source='user'` events of type `search`, `curate`, `show` or `feedback` in the last 90 days. A `.derived` hit also counts for its parent. Machine sources (`improve`, `task`, `audit`, `unknown`) are excluded. An asset outside the scope that no improve stage has processed and that is older than the window is left out. If state.db is unreadable, every asset stays eligible. *Why:* #986. (`src/indexer/usage/usage-events.ts:167-203`, `src/commands/improve/retrieval-scope.ts:1-92`, #986)
753
+ - **Show loop-break** — If `show` of the same ref was recorded 2 or more times in the previous hour, the response gains `showLoopWarning` (3+ shows). (`src/commands/read/show.ts:142-150`, `src/indexer/usage/show-usage.ts:20-31`)
754
+ - **Did-you-mean** — Search itself has none. It exists for unknown command names (edit distance at most `floor(len/3)`), unknown flags (at most `max(2, ceil(len/3))`), and an `akm improve <scope>` that matches no asset type (a warning only). (`src/cli.ts:969`, `src/cli/unknown-flags.ts:155-185`, `src/commands/improve/eligibility.ts:318-319`)
755
+ - **In-process curate API** — `akm-cli/api` `curate()` returns exactly the stdout of `akm --detail agent -q curate …`. (CHANGELOG 0.10.26101001; `src/api.ts:33-59`)
756
+
757
+ ## Config and compatibility
758
+
759
+ - **Version floor is 0.9.15** — 0.10 reads config, `state.db`, task sources, frozen plans and scheduler rows written by 0.9.15 or later. Older installs run `akm migrate apply` under akm 0.9.x first. An older shape is refused with one message and not converted: the retired `stashDir`/`sources[]`/`installed[]` keys, and `engines.*.extraParams` keys that shadow a first-class field. *Why:* #1155 (net -549 lines): no release at or above 0.9.0 wrote those shapes, and 0.9.15 only lifted `extraParams` in memory with a warning. (`src/core/config/config-schema.ts:175-190`, `docs/architecture/persisted-data-compat.md:11-19`, `STABILITY.md:20-26`, #1155, #1091)
760
+ - **Above the floor, tolerate and warn once** — A reader converts older shapes in memory, warns once, and names `akm migrate apply` as the on-disk cleanup. The only allowed refusal is data from a newer release, which names "upgrade akm". Task sources (`tasks/*.yml`) are the named exception: only v4 is read, and a v2/v3 file is refused per file. `akm task sync` reports it and continues with the other tasks. Every format bump needs an upgrade-rehearsal fixture. *Why:* #987 and the persisted-data contract. (`docs/architecture/persisted-data-compat.md:11-31,42`, #987)
761
+ - **No config object is strict; unknown keys are warned and dropped only by `migrate apply`** — Unknown keys at any depth are kept in memory and round-trip through ordinary writes. Each is named once per file: `Unknown config key "<dotted>" … has no defined akm behavior and is ignored`. `akm migrate apply` (`normalizeConfigFile`) rewrites config under a timestamped backup, dropping unknown keys and `index.graph/metadataEnhance/stalenessDetection`, and persisting `configVersion: "0.9.0"`. *Why:* so a newer release's settings survive a downgrade. Note: the header comment in `config-schema.ts` still says some sections are strict, but every section is `.passthrough()`. (`src/core/config/config.ts:247-273,854-880`, `src/core/config/config-schema.ts:23-33`)
762
+ - **`configVersion` is read, never gated** — The only value ever shipped is `"0.9.0"`. A missing field is accepted. Any other value is named once and read as `0.9.0`. `config set/unset configVersion` is refused, and `akm health`'s binary-config-skew advisory is what flags a newer config. (`src/core/config/config.ts:175-190`, `src/core/config/schema/primitives.ts:16`, `src/core/config/config-walker.ts:178-181`)
763
+ - **`config set/get/unset` behavior** — `set` of an unknown dotted key stores it with a warning (bare JSON or string). `get` and `unset` of an unknown key throw `Unknown config key`. Values are schema-validated, with enum, number and boolean coercion, and arrays or objects given as JSON. `defaultWriteTarget` must name a configured bundle. (`src/core/config/config-walker.ts:178-250`)
764
+ - **Protected `extraParams`** — `engines.<name>.extraParams` is refused (at config load and `config set`) for these keys at the top level, case and punctuation-insensitive: `model, messages, temperature, max_tokens, response_format, stream, stream_options, enable_thinking, reasoning_effort, chat_template_kwargs`. The message names the remedy, e.g. `set engines.<name>.enableThinking instead`. Credential-like keys (`authorization, headers, apikey, token, password, secret, cookie, set-cookie`) are refused at any depth. *Why:* #852 (name the fix), and #1155 (the legacy lift was removed). (`src/core/extra-params.ts:5-60,76-108`, `src/core/config/schema/primitives.ts:88`, #852, #1155)
765
+ - **Engine fields** — An `llm` engine requires `endpoint` (an `http(s)` URL; a path not ending in `/chat/completions` only warns) and `model`. Optional fields are `temperature`, `maxTokens`, `timeoutMs` (a positive integer up to 2^31−1, or `null`), `concurrency`, `contextLength`, `supportsJsonSchema`, `enableThinking`, `reasoningEffort`, `extraParams`. An unset temperature sends 0.3 at call time. `enableThinking` sends both `chat_template_kwargs.enable_thinking` and top-level `enable_thinking`. `reasoningEffort` sends `reasoning_effort`. *Why:* which wire form a backend honors varies (#949). (`src/core/config/schema/engines.ts:41-110`, `src/llm/client.ts:548-575`)
766
+ - **Engine request fallbacks (per endpoint|model, per process)** — A 4xx naming `chat_template_kwargs`/`enable_thinking` triggers one retry without them, remembered. A 4xx naming `temperature` drops it for the connection. A "use `max_completion_tokens`" 4xx switches the token field. A schema 4xx, or a 5xx naming the response format, retries once without the schema, and the retry is recorded as one successful call. *Why:* #1045 (OpenAI rejects `chat_template_kwargs`) and #1150 (a gateway answered 502 `format_ignored`). (`src/llm/client.ts:343-480`, #1045, #1150)
767
+ - **Agent-engine rules** — Agent engines need a `platform` that supports agent dispatch. LLM-only fields (`provider`, `endpoint`, `apiKey`, `temperature`, `maxTokens`, `extraParams`, `enableThinking`, `reasoningEffort` and others) are rejected on them. `llmEngine` is valid only on `opencode-sdk` and must name an `llm` engine. `args` is not valid on `opencode-sdk`. `agent` needs a harness with a native agent selector. Engine names are lowercase kebab, at most 63 chars, and must not start with `akm-`. (`src/core/config/schema/engines.ts:115-175`, `src/core/config/schema/primitives.ts:42`, `src/core/config/config-schema.ts:223-231`)
768
+ - **`apiKey` must be a reference (enforced at write, only warned at load)** — `engines.<n>.apiKey`, `embedding.apiKey` and `search.curateRerank.apiKey` should be `$VAR`, `${VAR}` or `secret://<name>`. `akm config set` of a literal key is refused: for `engines.<n>.apiKey` and `embedding.apiKey` with `apiKey must be $VAR, ${VAR}, or secret://<name>`; the "apiKey cannot be persisted in config; export AKM_ENGINE_<NAME>_API_KEY instead" text belongs to `rejectApiKeyPath`, which skips those two paths. A literal already in `config.json` loads with a one-time warning and is used as configured. Git bundle `credential` is strictly `$VAR` or `secret://`, and a violation is a load error. `apiKey` and `apiKeyFile` cannot both be set. An unset `$VAR` warns once and sends an empty credential, and an unresolved `secret://` throws `SECRET_REFERENCE_UNRESOLVED`. *Why:* a literal key leaks through backups, logs and version control. (`src/core/config/schema/primitives.ts:48-60`, `src/core/config/config-walker.ts:242-248,319-345`, `src/core/config/schema/sources-bundles.ts:73,191`, `src/core/config/config.ts:1063-1098`, #454, #905, #953)
769
+ - **`defaults.llmEngine` and model-work engine rule** — Every engine used for unattended model work must be able to confine the model-work tool policy: an `llm` engine, or an agent engine on a harness in `HARNESS_MODEL_WORK_IDS`. This is enforced at config load for `defaults.llmEngine`, `index.<pass>.engine`, improve strategy, process, judgment and `qualityGate` engines. The execution cascade is `defaults.llmEngine → strategy → index.<pass> → process → current invocation`. `defaults.engine` and `workflow.judgeEngine` must name a configured engine, and `defaults.improveStrategy` must be `default`, `consolidate` or a configured strategy, except that load exempts `graph-refresh` and the removed names (the error is raised at resolution; `graph-refresh` is refused there even with a user block and dropped only by `akm migrate apply`). The removed strategy names `quick, thorough, catchup, reflect-distill, proactive-maintenance` pass load but fail at resolution naming `default`. *Why:* unattended model work runs over generated content with no one watching; #1130. (`src/core/config/config-schema.ts:243-325`, `src/core/config/engine-semantics.ts`, `src/commands/improve/execution.ts:58-70`, #1130)
770
+ - **`extends` inheritance strips host-local settings** — An inherited layer loses `bundles, defaultBundle, defaultWriteTarget, embedding, execution, experimental, registries, scheduler, setup`. It also loses engine `apiKey/apiKeyFile/bin/args/workspace`, `search.curateRerank`, and improve strategy `sync`. Each strip warns once. *Why:* shared config contributes portable behavior only; credentials, executables, endpoints and publication policy stay host-local. (`src/core/config/config.ts:399-485`, #945)
771
+ - **Bundle entry shape** — A bundle needs exactly one of `path|git|website|npm`. `writable: true` is allowed only on `path` and `git`. `credential` is allowed only on `git`. `components` holds exactly one component. `defaultBundle` and `defaultWriteTarget` must name a configured, enabled bundle. Bundle keys must be legal slugs (no `/ : . #` or whitespace), and two bundles may not share one physical content root. (`src/core/config/schema/sources-bundles.ts:155-240`, `src/core/config/config-schema.ts:190-225`, `src/core/config/config.ts:290-297`)
772
+ - **Autonomy opt-in is explicit** — `experimental.improveAutonomy` is off whether absent or `false`, and is never inferred. It gates memory cleanup, memory-inference writes, and triage promote when judgment is on. `sync.push` is deliberately not gated by it. (`src/core/config/schema/experimental.ts:19-32`, `src/commands/improve/autonomy-gate.ts:10-20`)
773
+ - **Other defaults** — `feedback.requireReason` is true, output is `json`/`brief`, and the default registries are `akm-registry` (enabled) and `skills.sh` (disabled). `workflow.maxConcurrency`, `workflow.defaultMapConcurrency` and `workflow.judgeEngine` are described under Tasks, workflows and sync. (`src/core/config/config.ts:76-87`, `src/commands/feedback-cli.ts:644-648`)
774
+ - **Persisted-data compat by format** — `index.db` is layout 26: older layouts migrate in place and never drop `embeddings`, `utility_scores*` or `llm_enrichment_cache`, and a newer layout is refused with "Upgrade akm", leaving the file untouched. Only `SQLITE_CORRUPT` deletes and rebuilds it. `state.db` migrations are append-only; an older binary on a newer ledger continues with a warning, and a `.pre-<id>.bak` copy is taken before schema-dropping migrations. Frozen workflow plans are irVersion 6, and the irVersion 5 shape is still decoded; a plan that cannot be decoded is abandoned (status `failed`), never thrown. The legacy `{kind, ref, sourceId}` `scheduler.enabled` form is still read. *Why:* the `docs/architecture/persisted-data-compat.md` contract; #865. (`docs/architecture/persisted-data-compat.md:33-44`, `src/storage/repositories/index-entry-schema.ts:32`, `src/storage/repositories/index-schema.ts:61,245`, `src/core/state-db.ts:105`, `src/workflows/runtime/run-plan.ts:46,113`)
775
+
776
+ ## Tasks, workflows and sync
777
+
778
+ **Tasks and scheduler**
779
+
780
+ - **Task source is v4 only** — A task is `<bundle>/tasks/<id>.yml` with `version: 4`. It has exactly one of `uses` or `run`, from a closed top-level key set, and `schedule` is optional. Bounds include at most 64 schedule entries. Each bad file is reported on its own, and `akm task sync` skips it and keeps its installed row. *Why:* #987, which removed about 2,400 lines of v2/v3 read and convert code. (`src/tasks/source/task-source-v4.ts:84-120`, `src/tasks/source/bounded-document.ts:67`, #987)
781
+ - **Schedule grammar** — Five-field cron subset: `*`, integers, `A-B`, `*/N`, `A-B/N`, comma lists, MON/JAN names, and the aliases `@hourly/@daily/@midnight/@weekly/@monthly/@yearly/@annually`. `@reboot` is cron-only. A schedule is validated against the local backend: launchd and Task Scheduler reject steps or lists they cannot express, and both reject day-of-month plus day-of-week together. Task Scheduler allows at most 48 native triggers. Validation runs in `task sync` and in `akm lint --type tasks`, since `akm task validate` was folded into lint. *Why:* cron has OR semantics for dom and dow that other backends cannot express. (`src/tasks/schedule.ts:76-98,151-176,478-486,632-640`, `src/tasks/schedule-gates.ts:23-52`, `src/core/adapter/adapters/akm-lint.ts:313-314`, #1091)
782
+ - **Schedule inputs must satisfy the contract** — Each `schedule[i].inputs`, with defaults applied, must satisfy the task's declared `inputs`. It is checked at parse, at sync, and in lint, because a scheduled run supplies no input flags. A valid entry's inputs are delivered as `--<name> <value>` flags in the row's invocation. (`src/tasks/source/task-source-v4.ts:575-612`, `src/tasks/schedule-gates.ts:23`, `src/tasks/scheduler-binding.ts:108-130`)
783
+ - **Activation is host-local** — `scheduler.enabled` in `config.json` lists canonical refs (`bundle//tasks/x`). A ref not on the list is disabled, and installing a bundle schedules nothing. When the list is absent (a 0.9.15-style config), the first `sync`, `enable` or `disable` adopts the akm-written installed rows as the list, with a warning. A non-canonical entry is skipped with a warning. `enable` fails for an unconfigured/inactive bundle, or a task with no `schedule`. A source cannot authorize its own scheduling. (`src/tasks/activation-config.ts:15-24,45-62,85-111`, `src/commands/tasks/tasks.ts:356-417,575`, `src/tasks/source/task-source-v4.ts`)
784
+ - **Sync reconcile semantics** — Sync reads only enabled refs, and each source compiles on its own. A source that fails to parse or prepare is reported in `failures`, its installed row is left exactly as is (`keepRefs`), the others still reconcile, and the exit code is non-zero. Rows are installed when missing, updated when the rendered signature differs, and removed when the source is gone or no longer enabled. Removals are ordered before writes. Two sources claiming one native id (compared case-insensitively) are neither installed. Rows owned by another bundle or installation, or outside akm's markers, are never touched. An unscoped sync removes the rows of a disabled bundle. Only filesystem and git bundles can carry schedules. *Why:* #867 (one bad task disabled all sync) and #846 (a name-scoped reconcile risked deleting another bundle's rows). (`src/tasks/scheduler-sync.ts:71-120,303-400`, `src/commands/tasks/tasks.ts:498-541,552-672`, `src/commands/tasks/tasks-cli.ts:391`, #867, #846)
785
+ - **Row shape and ownership** — A row is `<launcher> task run <id> --bundle <bundle> --scheduled [inputs]`, with its environment set inline (always `AKM_BUNDLE_DIR` = the syncing shell's working stash). Rows are owned by that path, not by a display name. A plain sync keeps an installed row's launcher, and `--rebind` repoints it. A cron command over 1,000 bytes runs a wrapper script (`.akm-cron-wrapper-<id>-<hash>.sh`) instead. Rows from 0.9.0 to 0.9.17-alpha.6 carry `--scheduler-context` and keep firing until the next plain sync rewrites them. *Why:* #846. (`src/tasks/scheduler-binding.ts:108-130`, `src/tasks/scheduler-sync.ts:418-433`, `src/tasks/backends/cron.ts:99`, `docs/reference/tasks.md:Operations`)
786
+ - **Scheduler writes are serialized** — `sync|add|enable|disable|prune --yes` hold `$STATE/locks/scheduler.lock`: PID-liveness only, re-entrant in-process. A second process gets `SCHEDULER_LOCK_HELD` (exit 75). (`src/tasks/scheduler-lock.ts:27-52`)
787
+ - **Dry-run and prune** — `task sync --dry-run` writes nothing and exits non-zero if the plan has removals. `task prune` previews by default and removes only rows whose `AKM_BUNDLE_DIR` is gone (`dead-bundle-path`) or whose legacy descriptor is unreadable (`invalid-context`), and `--id` must be a current candidate. *Why:* #849 and #851, since sync is the only destructive task subcommand. (`src/commands/tasks/tasks-cli.ts:357-396`, `src/commands/tasks/tasks.ts:739-800`, #849, #851)
788
+ - **Task run order and history** — All parsing and preparation finish before an attempt is reserved, so an invalid task creates no history row or log. The attempt is then reserved in `task_history` (`status: active`, a unique start time bumped by 1 ms on collision). It is finalized as completed or failed, and a history-write failure only warns and never changes the task result. Persisted failure codes are an allowlist, otherwise `INTERNAL`. `task run` exits 0 for completed/active and 1 for failed/blocked, except a shell or script exit 78 passes through as 78. (`src/tasks/run/run-task.ts:35-60`, `src/tasks/run/attempt-lifecycle.ts:59-80,111-132`, `src/tasks/run/task-history.ts:50-80`, `src/tasks/run/task-result.ts:167`, `src/commands/tasks/tasks.ts:339`)
789
+ - **Task logs are redacted before write** — Output goes to `<logDir>/<id>/<timestamp>.log` and to `logs.db` rows, after credential-pattern redaction plus exact secret values (env/config-derived and the task's `redact:` names). Multi-line secrets are rejoined before scrubbing. Both sinks are best-effort. *Why:* #755. (`src/tasks/run/task-log.ts:153-184,240-296`, #755)
790
+ - **Default 6-hour timeout for unattended tasks** — A workflow task, a command task (`uses: commands/…`) or a native shell or script task with no `timeout:` is bounded at 6 h (`DEFAULT_SCHEDULED_TASK_TIMEOUT_MS`). `timeoutMs: null` opts out. A command task gets the default only when neither the task's `timeout:` nor the asset's own `akm.timeout` or the engine sets one (fixed in this PR, #4). Native shell and script tasks get a process-group SIGTERM then SIGKILL on timeout. *Why:* nobody watches a scheduled run, so one wedged run would silently stop the schedule; 6 h matches `akm health`'s stale-run window and fits inside `@daily`. (`src/tasks/run/run-workflow-task.ts:60-74`, `src/tasks/run/run-native-task.ts:170`, `src/tasks/run/run-command-task.ts:58-65`, `src/tasks/prepare/prepare-support.ts:81-106`, `src/tasks/prepare/prepared-execution.ts:63-90`)
791
+ - **Log and event retention is 90 days and runs only inside improve** — `logs.db` rows and `<logDir>/**/*.log` older than `improve.eventRetentionDays` (default 90; 0 disables) are purged by improve's maintenance stage, not by `task run`. The per-run improve and index logs under `$CACHE/logs/improve/` and `$CACHE/logs/index/` are purged by the same pass (fixed in this PR, #14). (`src/commands/improve/loop-stages.ts:730-797`, `src/core/logs-db.ts:310`, `src/tasks/run/task-log.ts:65,105-137`)
792
+ - **`--skip-if-locked`** — On `akm index`: exits 0 with `skipped: lock-held`. On `akm improve`: exits 0 and warns `another improve run holds the lock`; without the flag it exits 75 `IMPROVE_LOCK_HELD`. On `akm workflow run`: only `RUN_LEASE_HELD` and `STATE_DB_CONTENDED` become a graceful skip, and any other error still fails. `akm bundle update --skip-if-locked` also exists (`bundle-cli.ts:143-147,176-181`). *Why:* #956 and #948, so frequent scheduled runs do not pile up. (`src/commands/sources/stash-cli.ts:90-112`, `src/commands/improve/locks.ts:43-90`, `src/commands/workflow-cli.ts:195-210`, #948, #956)
793
+ - **Shipped task templates** — `core/sync` runs `akm sync` every 15 minutes. `core/improve` runs `akm improve --require-engines` at 02:00 nightly. `core/index-refresh` runs `akm index --skip-if-locked` at 04:00. `core/extract` runs `akm proposal extract --auto` every 30 minutes (opt-in). `core/version-check` runs `akm upgrade --check` on Mondays at 09:00. `improve/akm-improve-consolidate` runs `akm improve --strategy consolidate --timeout-ms 3600000 --skip-if-locked --require-engines` every 4 h at :20. The two cannot collide at 02:00 (fixed in this PR, #6): `core/improve` keeps no `--skip-if-locked`, because the nightly must run or fail loudly, and the consolidate task is bounded to one hour, so the 00:20 run ends by 01:20 instead of holding `improve.lock` for its 2-hour default budget until 02:00. `--skip-if-locked` sits on the frequent task, which is the one that can lose a contended slot. `docs/guides/scheduling.md` no longer recommends the flag for a once-a-night run, because it would turn a collision into a silent exit-0 skip. *Why:* a nightly that loses the lock throws `IMPROVE_LOCK_HELD` (exit 75), is recorded as a terminated "exception" row, and the task runner does not retry. (`src/assets/tasks/core/*.yml`, `src/assets/tasks/improve/akm-improve-consolidate.yml:2`, `docs/guides/scheduling.md:82-88`, `locks.ts:86-89`)
794
+ - **Scheduler delivery is at-least-once** — An ambiguous crash can re-run external work, so scheduled side effects should be idempotent. (`docs/reference/tasks.md` Operations)
795
+
796
+ **Workflows (the whole surface is Experimental; STABILITY.md)**
797
+
798
+ - **Workflow source is Markdown only** — Step ids match `[A-Za-z_][A-Za-z0-9_-]*`. Every `##` heading must be a declared step id. An optional `### gate` section holds the rubric. A canonical ref must be owned by exactly one recognized `.md`. (`src/workflows/parser.ts:110`, `src/workflows/compile.ts:41-47`, `src/workflows/source-files.ts:69`, `docs/reference/workflow-schema.md:Body structure`)
799
+ - **A run executes a frozen plan** — `akm workflow run <ref>` freezes an irVersion 6 plan (`plan_json`, `plan_hash`, `sourceHash`) at start. Resume reads only the frozen plan and never re-reads config, the index, or the source, other than to warn once if the source changed. A stored plan that does not decode is abandoned (status `failed`) with a message naming `akm workflow run <ref>`. Models, tools, working directory and runner are frozen. Workflow environment pass-through values and env/secret references are materialized at dispatch. *Why:* #919 (editing a workflow broke in-flight runs). (`src/workflows/plan.ts:28`, `src/workflows/freeze/freeze.ts:36-100`, `src/workflows/exec/run-workflow.ts:312-345`, `docs/architecture/workflow-engine.md:Frozen plans`, #919)
800
+ - **Param defaults are applied at run start** — Every declared `default:` fills a param the run was not given, from flags, task inputs or an object. Explicit values win, params are never required, and supplied-plus-default values are schema-validated. Flags and an object cannot be mixed (`Workflow parameters must use either an object or per-parameter flags, not both`). Params are declared non-secret, with an advisory warning for secret-shaped values. *Why:* #1162, since defaults were previously accepted but never applied and prompts showed `Run parameters: {}`. (`src/workflows/ir/params.ts:89`, `src/workflows/runtime/runs.ts:241-262,337`, #1162)
801
+ - **One active run per (ref, scope)** — Starting a ref with an active run in the same scope resumes it. `--new` starts a separate run and leaves the old one untouched. Scope is the nearest `.akm/config.json`, else git root, else the stash if cwd is inside it, else cwd. An active run of the same ref in another scope only produces a warning. *Why:* #485 (zero concurrency control) and #942 (a scheduled task's cwd differed from the shell's). (`src/workflows/authoring/scope-key.ts:12-33`, `src/workflows/runtime/runs.ts:286-306`, #485, #942, #919)
802
+ - **One engine drives a run** — `workflow run` takes an O_EXCL lock file per run (`workflow-run-locks/<run id>.lock`). A second process gets `RUN_LEASE_HELD` (exit 75). A dead holder is reclaimed at once; nothing expires by age. A run that is not `active` is refused before dispatch, with a hint to `akm workflow resume`. (`src/workflows/exec/run-workflow.ts:226-239,312-330`)
803
+ - **Dispatch is at-least-once with unit reuse** — Units are journaled under a content-derived id plus an input hash (`hashVersion` 7). A `completed` unit is reused on resume and never re-dispatched, while failed or missing units are dispatched. An ambiguous crash can duplicate a side effect. (`docs/architecture/workflow-engine.md:Resume skips completed units`, `src/workflows/exec/step-work.ts:513-521`)
804
+ - **Map fan-out width** — A `map` step with no `concurrency:` freezes width 4. A step's real width is the minimum of its own `concurrency`, the run's frozen `workflow.maxConcurrency` (unset → `min(16, max(1, cores−2))`; set → clamped to [1, 64]), the LLM engine's concurrency (1 for loopback endpoints, 4 for remote, or `engines.<n>.concurrency`), and the host CPU cap re-derived at dispatch. Loopback detection is purely syntactic. *Why:* local model servers fail hard on concurrent inference; one config must work across machines. (`src/workflows/concurrency-policy.ts:12-59`, `src/workflows/resource-limits.ts:16`, `src/workflows/freeze/freeze.ts:96`, `docs/reference/workflow-schema.md:Fan-out`)
805
+ - **Exec (shell) units** — `exec.command` is an argv array, never a shell string, and no engine, model or llm is allowed beside it. The default timeout is 600,000 ms unless the unit or `defaults.timeout` sets one (`timeout: none` opts out). Stdout and stderr retention is 8 MiB each: past that, a unit with no `output:` schema succeeds with a `__akm_exec_output_truncated__` marker appended, and a unit with an `output:` schema fails `exec_output_limit`. `exec_context_too_large`, `exec_cwd_escape` and `exec_capture_incomplete` are not retryable. (`src/workflows/resource-limits.ts:31,39`, `src/workflows/freeze/step-values.ts:83-95`, `docs/reference/workflow-schema.md:Exec units`)
806
+ - **Failure policy** — Fail-fast is the default, and `on_error: continue` lets the completion gate decide. `retry: {max, on}` requires `max ≥ 0` and a non-empty `on` list of failure reasons (`timeout, spawn_failed, non_zero_exit, parse_error, cooldown, llm_rate_limit, llm_content_filter, llm_invalid_json, content_policy_reject, unsupported_type, no_change, quality_rejected, aborted`). A unit `output` schema miss re-dispatches once with corrective feedback; an exec unit fails immediately instead. Budget ceilings (`max_units`, `max_tokens`) are run-lifetime counters seeded from the journal and are a hard stop that ignores `on_error: continue`. (`src/workflows/parser.ts:84-103,1155-1190`, `docs/reference/workflow-schema.md:Failure policy,Budget ceilings`)
807
+ - **Gates are fail-closed** — A non-empty `### gate` rubric enables mandatory verification. The judge sees the step's artifact as canonical JSON clipped at 4,000 chars plus the byte-exact rubric. Only a well-formed `complete: true` verdict advances the step. A missing judge, a dispatch failure or a malformed verdict rejects or blocks. `gate.max_loops` (default 1) bounds the evaluator-optimizer retries, and only `command` targets loop. Shell and child-workflow steps are judged once. At freeze, a missing verification engine is a warning, not an error: the step blocks for `akm workflow resume` when reached. *Why:* a gate judging a machine summary like "Executed 3 units" proves nothing; re-running a frozen argv would repeat side effects. (`src/workflows/exec/step-work.ts:54,729-738,975-983,1237`, `src/workflows/freeze/freeze.ts:73-88,114-127`, `src/workflows/parser.ts:369,1115-1120`)
808
+ - **Child workflows (Experimental, #1165)** — A step declares `unit: { workflow: workflows/<name>, with: {param: literal | {from: ref}} }`. The child is compiled and frozen whole, embedded in the parent's frozen target at freeze, and never re-read at dispatch. Only a composition cycle is checked (`COMPOSITION_INVALID`). `workflow` cannot sit beside `exec/engine/model/llm/env` or inside `map.unit`. The child is published idempotently, keyed by `{parentRunId, parentUnitId, unitInputHash}`, and driven inline with the same engine. A child already `blocked` or `failed` is not re-driven. The child's final status maps onto the composing step, and a completed child returns its last step's output. A top-level `outputs:` key no longer exists. Child runs are hidden from `workflow list` unless `--children`. *Why:* #1096 owner decision B: keep a simple, Experimental form. (`src/workflows/freeze/targets/child-workflow.ts:67-125`, `src/workflows/exec/child-workflow.ts:209-357`, `docs/reference/workflow-schema.md:Child workflows`, #1165)
809
+ - **Features removed in 0.10** — The removed features are `route` steps, `unit.isolation: worktree`, top-level `outputs:`, the GitHub-shaped YAML format, and workflow-step `uses: tasks|scripts|commands`. They are now lint errors or unknown keys, and a stored plan containing a route step cannot be decoded, so that run is abandoned. A task that targets a workflow (`uses: workflows/<ref>`) is unchanged. *Why:* #1096/#1165, unused by any found workflow; −12.6k lines. (CHANGELOG 0.10.26101001 "Removed", #1165)
810
+ - **`workflow run` CLI** — `--max-steps` must be at least 1 and `--max-retries` at least 0. `--timeout` is deliberately not defaulted for interactive runs. SIGINT and SIGTERM abort gracefully (exit 130/143), leaving the run resumable. `abandon` marks a run failed and `resume` reopens it. (`src/commands/workflow-cli.ts:209-250,454-480`)
811
+
812
+ **Sync**
813
+
814
+ - **`akm sync` commits, merges the upstream, then pushes** — A non-git directory is skipped with `reason: "not a git repository"`. Staging is scoped to managed paths (the exact `--paths`, else the bundle's content root or managed adapter directories), and broad `git add -A` is never used. The commit is made by an exact-path compare-and-swap. A writable bundle with a remote and `--push` (the default) then runs `git fetch`, and brings the upstream into HEAD: nothing when HEAD already has it, a fast-forward when HEAD is behind, otherwise git's default merge. This also happens when there is nothing to commit, so a host with no changes of its own keeps up. A merge that conflicts is aborted: the working tree and local commits stay as they were, the commit is kept, only the push is skipped, and `reason` reads `not pushed: merging upstream conflicts in <files>…` (a failed fetch gives `could not fetch…`); the next sync tries again. A push needs writable plus a remote plus an upstream; it uses `--force-with-lease` with a 120 s timeout. `--no-push`, a read-only bundle and a bundle without a remote never touch the network. A push rejected anyway (another host pushed during the sync) still raises `GitStashPushError`, which carries the commit that landed. The message is sanitized (fixed in this PR, #5). *Why:* a stale local tracking ref made the lease push fail after another host pushed, so a second host could never converge; stop an unrelated dirty tree being swept into the remote, and commit-message trailer forgery. (`src/sources/providers/git-stash.ts:154-161,180,246-420,422-504,585-605`, `src/commands/sources/sources-cli.ts:90-155`, `src/cli/shared.ts:73`, `src/sources/providers/git-provider.ts:21,107-121`)
815
+ - **After improve: end-of-run auto-sync** — For a git-backed primary bundle, improve commits as a single batch via `saveGitStash`. The strategy block `sync: {enabled, push, message}` defaults to enabled and push true (the defaults come from `default.json:17` and `improve.ts:942`; the schema has every field optional; `akm improve --no-sync --no-push` override). The commit stages only the paths this run wrote, from the write-provenance journal, still reported changed by Git. Paths dirtied by others during the run are left. A failure is non-fatal and warns. It appends a `stash_synced` event with `phase` (`end-of-run`; the pre-run sync records `pre-run`) and `committed/pushed/skipped/reason/attributed/unattributed`. `sync.push` is not behind `experimental.improveAutonomy`. *Why:* #662, so an interrupted run does not leave an uncommitted backlog. (`src/commands/improve/improve.ts:363,1011-1049`, `src/commands/improve/autonomy-gate.ts:17-19`, `src/assets/improve-strategies/default.json:17`)
816
+ - **Before improve: the same sync as `akm sync` (changed in this PR, decision 7)** — On a live run of a git-backed bundle with sync enabled, improve first runs `saveGitStash` with the same fetch, fast-forward-or-merge, commit and push that `akm sync` runs (see Pre-run sync), so the blocking `ensureIndex` that follows and the plan see what another host pushed. The pre-run and end-of-run syncs share one helper, `runStashSync`. A directory that is not a git repository is not synced. The blocking `ensureIndex` hydrate of a URL-configured writable `git` bundle (a `git merge --ff-only` under a 12-hour TTL) is separate and still runs. Scheduled `core/sync` (every 15 min) is a separate task. *Why:* #1091; a conflict is not resolved by an agent because the owner wants an agent only if needed (Policy decision 7). (`src/commands/improve/improve.ts:286-288,606,915-1004`, `src/indexer/ensure-index.ts:219-247`, `src/sources/providers/git-provider.ts:107-121,161-180`, `src/assets/tasks/core/sync.yml`)
817
+
818
+ ### Code and doc mismatches
819
+ - `docs/reference/workflow-schema.md` documents `gate.max_loops` as 1–100 and `retry.max` as 0–100. The parser and JSON schema enforce only `≥ 1` and `≥ 0`.
820
+ - `docs/architecture/workflow-engine.md:65-66` claims three composition bounds (depth, cycle, aggregate embedded bytes) enforced at freeze; `docs/reference/workflow-schema.md:80-84` documents the cycle alone; `src/workflows/freeze` implements only the cycle check (`child-workflow.ts:67-76,88`; `freeze.ts:64` recurses unbounded).
821
+ - `docs/reference/workflow-schema.md` says a gate rubric requires `workflow.judgeEngine` before freezing. The code falls back to default engine resolution and, if none is available, warns at freeze and blocks the step at run time.
822
+ - `docs/guides/scheduling.md:63-64` says `irVersion: 5` (now 6); `workflow-engine.md:56-59` still lists removed `uses:` forms and the `script` target.
823
+ - The shipped hint files (`cli-hints-short.md:11,42`, `cli-hints-full.md:96-104`) and `docs/reference/cli.md:1792-1795` tell agents to run `--positive` with no reason, which starves distill (a positive with no reason or note is skipped there); `cli.md:1793` shows `akm feedback agents/reviewer --negative` with no `--reason`, a usage error by default.
824
+ - `docs/architecture/workflow-engine.md` still says "Markdown or GitHub-Actions-style YAML".
825
+ - The `src/core/config/config-schema.ts` header says `scheduler`, `execution`, `experimental` and reranker policy are strict. All are `.passthrough()`.
826
+ - `src/tasks/schedule.ts` header says dom+dow is rejected generally; only the launchd and Task Scheduler translators reject it.
827
+ - The `src/indexer/passes/memory-inference.ts` header says `akm index` runs the pass; it is called from improve.