akm-cli 0.7.5 → 0.8.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
package/dist/cli.js CHANGED
@@ -1,40 +1,83 @@
1
1
  #!/usr/bin/env bun
2
+ // This Source Code Form is subject to the terms of the Mozilla Public
3
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ // Runtime guard: akm-cli 0.8 is Bun-only. The `preinstall` hook in
6
+ // package.json blocks `npm install`, but it does not protect against a
7
+ // stale node-resolved shebang, a wrong PATH entry, or someone running
8
+ // `node dist/cli.js` directly from a clone. In any of those cases the
9
+ // next line — `import { spawnSync } from "node:child_process";` — would
10
+ // itself succeed under node, only to die a few imports later with a
11
+ // confusing `ERR_MODULE_NOT_FOUND` for our extensionless internal paths.
12
+ // Catch the wrong-runtime case here with a friendly message instead of
13
+ // a stack trace. Cross-runtime support is planned for 0.9 (issue #465).
14
+ if (typeof globalThis.Bun === "undefined") {
15
+ console.error("\n ERROR: akm-cli 0.8 requires the Bun runtime (https://bun.sh) or the prebuilt binary.\n" +
16
+ " Running under Node.js is not supported in this release.\n" +
17
+ " Install options:\n" +
18
+ " 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\n" +
19
+ " 2. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n" +
20
+ " Cross-runtime support is planned for 0.9.0.\n");
21
+ process.exit(1);
22
+ }
2
23
  import { spawnSync } from "node:child_process";
3
24
  import fs from "node:fs";
4
25
  import path from "node:path";
5
26
  import * as p from "@clack/prompts";
6
27
  import { defineCommand, runMain } from "citty";
28
+ import { getStringArg, hasSubcommand, parseAutoAcceptFlag, parseNonNegativeIntFlag, parsePositiveIntFlag, } from "./cli/parse-args";
29
+ import { akmAgentDispatch } from "./commands/agent-dispatch";
7
30
  import { generateBashCompletions, installBashCompletions } from "./commands/completions";
8
31
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./commands/config-cli";
9
32
  import { akmCurate } from "./commands/curate";
10
- import { akmDistill } from "./commands/distill";
33
+ import { akmDbBackups } from "./commands/db-cli";
11
34
  import { akmEventsList, akmEventsTail } from "./commands/events";
35
+ import { akmGraphEntities, akmGraphEntity, akmGraphExport, akmGraphOrphans, akmGraphRelated, akmGraphRelations, akmGraphSummary, akmGraphUpdate, } from "./commands/graph";
36
+ import { akmHealth } from "./commands/health";
12
37
  import { akmHistory } from "./commands/history";
38
+ import { akmImprove } from "./commands/improve";
39
+ import { buildImproveRunId, relativeImproveResultPath, writeImproveResultFile } from "./commands/improve-result-file";
13
40
  import { assembleInfo } from "./commands/info";
14
41
  import { akmInit } from "./commands/init";
15
42
  import { akmListSources, akmRemove, akmUpdate } from "./commands/installed-stashes";
43
+ import { inferAssetName, readKnowledgeInput, writeMarkdownAsset } from "./commands/knowledge";
44
+ import { akmLint } from "./commands/lint";
16
45
  import { renderMigrationHelp } from "./commands/migration-help";
17
- import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalShow, } from "./commands/proposal";
46
+ /**
47
+ * Resolve the event source from the environment. When `AKM_EVENT_SOURCE` is
48
+ * set (e.g. by `akm improve` for agent subprocesses), events are tagged so
49
+ * they can be filtered out of user-facing history.
50
+ */
51
+ function resolveEventSource() {
52
+ const raw = process.env.AKM_EVENT_SOURCE;
53
+ if (raw === "improve")
54
+ return "improve";
55
+ if (raw === "user")
56
+ return "user";
57
+ return undefined;
58
+ }
59
+ import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, } from "./commands/proposal";
18
60
  import { akmPropose } from "./commands/propose";
19
- import { akmReflect } from "./commands/reflect";
20
61
  import { searchRegistry } from "./commands/registry-search";
21
- import { buildMemoryFrontmatter, parseDuration, readMemoryContent, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
22
- import { akmSearch, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
62
+ import { buildMemoryFrontmatter, parseDuration, readMemoryContent, resolveRememberContentArg, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
63
+ import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
23
64
  import { checkForUpdate, performUpgrade } from "./commands/self-update";
24
- import { akmShowUnified } from "./commands/show";
65
+ import { akmShowUnified, normalizeShowArgv } from "./commands/show";
25
66
  import { akmAdd } from "./commands/source-add";
26
67
  import { akmClone } from "./commands/source-clone";
27
68
  import { addStash } from "./commands/source-manage";
69
+ import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./commands/tasks";
28
70
  import { parseAssetRef } from "./core/asset-ref";
71
+ import { assembleAsset } from "./core/asset-serialize";
29
72
  import { deriveCanonicalAssetName, resolveAssetPathFromName } from "./core/asset-spec";
30
- import { isHttpUrl, isWithin, resolveStashDir, tryReadStdinText } from "./core/common";
31
- import { DEFAULT_CONFIG, getConfigPath, loadConfig, loadUserConfig, saveConfig } from "./core/config";
73
+ import { isHttpUrl, isWithin, resolveStashDir, writeFileAtomic } from "./core/common";
74
+ import { DEFAULT_CONFIG, FEEDBACK_FAILURE_MODES, loadConfig, loadUserConfig, resolveConfiguredSources, saveConfig, } from "./core/config";
32
75
  import { ConfigError, NotFoundError, UsageError } from "./core/errors";
33
76
  import { appendEvent } from "./core/events";
34
- import { getCacheDir, getDbPath, getDefaultStashDir } from "./core/paths";
35
- import { setQuiet, setVerbose, warn } from "./core/warn";
36
- import { resolveWriteTarget, writeAssetToSource } from "./core/write-source";
37
- import { closeDatabase, findEntryIdByRef, openExistingDatabase } from "./indexer/db";
77
+ import { parseFrontmatter, parseFrontmatterBlock } from "./core/frontmatter";
78
+ import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
79
+ import { clearLogFile, info, isQuiet, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
80
+ import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, openExistingDatabase } from "./indexer/db";
38
81
  import { ensureIndex } from "./indexer/ensure-index";
39
82
  import { akmIndex } from "./indexer/indexer";
40
83
  import { resolveSourceEntries } from "./indexer/search-source";
@@ -47,16 +90,22 @@ import { buildRegistryIndex, writeRegistryIndex } from "./registry/build-index";
47
90
  import { resolveSourcesForOrigin } from "./registry/origin-resolve";
48
91
  import { saveGitStash } from "./sources/providers/git";
49
92
  import { resolveAssetPath } from "./sources/resolve";
50
- import { fetchWebsiteMarkdownSnapshot } from "./sources/website-ingest";
51
93
  import { pkgVersion } from "./version";
52
94
  import { createWorkflowAsset, formatWorkflowErrors, getWorkflowTemplate, validateWorkflowSource, } from "./workflows/authoring";
53
95
  import { hasWorkflowSubcommand, parseWorkflowJsonObject, parseWorkflowStepState, WORKFLOW_STEP_STATES, } from "./workflows/cli";
54
96
  import { completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, listWorkflowRuns, resumeWorkflowRun, startWorkflowRun, } from "./workflows/runs";
55
- const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
56
97
  const SKILLS_SH_NAME = "skills.sh";
57
98
  const SKILLS_SH_URL = "https://skills.sh";
58
99
  const SKILLS_SH_PROVIDER = "skills-sh";
59
100
  import { stringify as yamlStringify } from "yaml";
101
+ function applyEarlyStderrFlags(argv) {
102
+ if (argv.includes("--quiet") || argv.includes("-q")) {
103
+ setQuiet(true);
104
+ }
105
+ if (argv.includes("--verbose")) {
106
+ setVerbose(true);
107
+ }
108
+ }
60
109
  /**
61
110
  * Collect all occurrences of a repeatable flag from process.argv.
62
111
  * Citty's StringArgDef only exposes the last value when a flag is repeated,
@@ -80,6 +129,43 @@ function parseAllFlagValues(flag) {
80
129
  }
81
130
  return values;
82
131
  }
132
+ function resolveHelpMigrateVersionArg(version) {
133
+ if (version === undefined)
134
+ return undefined;
135
+ const parsedFormat = parseFlagValue(process.argv, "--format");
136
+ if (parsedFormat !== undefined &&
137
+ version === parsedFormat &&
138
+ wasHelpMigrateFlagValueConsumedAsVersion(version, parsedFormat, "--format")) {
139
+ return undefined;
140
+ }
141
+ const parsedDetail = parseFlagValue(process.argv, "--detail");
142
+ if (parsedDetail !== undefined &&
143
+ version === parsedDetail &&
144
+ wasHelpMigrateFlagValueConsumedAsVersion(version, parsedDetail, "--detail")) {
145
+ return undefined;
146
+ }
147
+ return version;
148
+ }
149
+ function wasHelpMigrateFlagValueConsumedAsVersion(version, flagValue, flagName) {
150
+ const argv = process.argv.slice(2);
151
+ const helpIndex = argv.indexOf("help");
152
+ const tokens = helpIndex >= 0 ? argv.slice(helpIndex + 1) : argv;
153
+ const migrateIndex = tokens.indexOf("migrate");
154
+ const relevant = migrateIndex >= 0 ? tokens.slice(migrateIndex + 1) : tokens;
155
+ let flagIndex = -1;
156
+ for (let i = 0; i < relevant.length; i += 1) {
157
+ const token = relevant[i];
158
+ if (token === flagName || token === `${flagName}=${flagValue}`) {
159
+ flagIndex = i;
160
+ break;
161
+ }
162
+ }
163
+ if (flagIndex === -1)
164
+ return false;
165
+ if (relevant.slice(0, flagIndex).includes(version))
166
+ return false;
167
+ return relevant[flagIndex] === flagName ? relevant[flagIndex + 1] === version : true;
168
+ }
83
169
  function output(command, result) {
84
170
  const mode = getOutputMode();
85
171
  const shaped = shapeForCommand(command, result, mode.detail, mode.forAgent);
@@ -101,6 +187,30 @@ function output(command, result) {
101
187
  }
102
188
  }
103
189
  }
190
+ /**
191
+ * Stderr-only human-friendly hint after a non-interactive `setup` invocation.
192
+ * Default --format is `json`, so a CI or piped consumer sees only the JSON on
193
+ * stdout. But an interactive user running `akm setup --yes` would otherwise
194
+ * see only the JSON blob with no obvious next step. When stderr is a TTY and
195
+ * the JSON went to stdout, print a two-line summary to stderr telling the
196
+ * user (a) where the stash landed and (b) what to run next.
197
+ *
198
+ * Silent when: stderr is not a TTY (CI, pipes), --format=text/yaml (the user
199
+ * already gets readable output), --quiet, or the result is missing fields.
200
+ */
201
+ function printSetupTtyHint(result) {
202
+ if (!process.stderr.isTTY)
203
+ return;
204
+ const mode = getOutputMode();
205
+ if (mode.format !== "json" && mode.format !== "jsonl")
206
+ return;
207
+ if (isQuiet())
208
+ return;
209
+ if (!result?.stashDir)
210
+ return;
211
+ console.error(`\n✓ Stash created at ${result.stashDir}\n` +
212
+ ` Next: \`akm add github:itlackey/akm-stash\` then \`akm index\` to populate the stash.`);
213
+ }
104
214
  /**
105
215
  * Module Naming:
106
216
  * - sources/* : Asset operations (search, show, add, clone)
@@ -111,12 +221,82 @@ function output(command, result) {
111
221
  const setupCommand = defineCommand({
112
222
  meta: {
113
223
  name: "setup",
114
- description: "Interactive configuration wizard: detects services and walks you through embeddings, LLM, registries, sources, and agent profiles. Writes config once at the end.",
224
+ description: "Interactive configuration wizard. Configures embeddings/LLM connections (for indexing/enrichment), agent profiles (CLI agent, embedded SDK, or none), sources, and registries. Shows which features are enabled at the end. Use --config <json> or --yes for non-interactive/scripting mode.",
115
225
  },
116
- async run() {
226
+ args: {
227
+ config: {
228
+ type: "string",
229
+ description: 'Config JSON to apply non-interactively, e.g. \'{"llm":{"endpoint":"...","model":"..."}}\'',
230
+ },
231
+ from: {
232
+ type: "string",
233
+ description: "Path to a config file (JSON or YAML) to bootstrap from. Skips prompts for keys present in the file.",
234
+ },
235
+ yes: {
236
+ type: "boolean",
237
+ default: false,
238
+ description: "Accept all defaults, skip all prompts. Idempotent — safe to run in CI.",
239
+ },
240
+ dir: {
241
+ type: "string",
242
+ description: "Stash directory path (overrides stashDir in config or --config JSON)",
243
+ },
244
+ probe: {
245
+ type: "boolean",
246
+ default: false,
247
+ description: "Probe LLM/embedding endpoints after writing config to verify connectivity",
248
+ },
249
+ },
250
+ async run({ args }) {
117
251
  await runWithJsonErrors(async () => {
118
- const { runSetupWizard } = await import("./setup/setup");
119
- await runSetupWizard();
252
+ const noInit = getHyphenatedBoolean(args, "no-init");
253
+ if (args.from && args.config) {
254
+ throw new UsageError("Pass either --from <file> or --config <json>, not both.", "INVALID_FLAG_VALUE");
255
+ }
256
+ if (args.from) {
257
+ // File-based bootstrap. `loadSetupConfigFromFile` expands a leading
258
+ // `~`, resolves relative paths against cwd, picks the YAML or JSON
259
+ // parser based on the file extension, and surfaces any
260
+ // read/parse/shape errors as ConfigError("INVALID_CONFIG_FILE").
261
+ const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup");
262
+ const loaded = await loadSetupConfigFromFile(args.from);
263
+ const result = await runSetupFromConfig({
264
+ configJson: loaded.configJson,
265
+ dir: args.dir,
266
+ noInit,
267
+ probe: args.probe,
268
+ });
269
+ output("setup", result);
270
+ printSetupTtyHint(result);
271
+ }
272
+ else if (args.config) {
273
+ // Non-interactive config mode
274
+ const { runSetupFromConfig } = await import("./setup/setup");
275
+ const result = await runSetupFromConfig({
276
+ configJson: args.config,
277
+ dir: args.dir,
278
+ noInit,
279
+ probe: args.probe,
280
+ });
281
+ output("setup", result);
282
+ printSetupTtyHint(result);
283
+ }
284
+ else if (args.yes) {
285
+ // Defaults mode — no prompts
286
+ const { runSetupWithDefaults } = await import("./setup/setup");
287
+ const result = await runSetupWithDefaults({
288
+ dir: args.dir,
289
+ noInit,
290
+ probe: args.probe,
291
+ });
292
+ output("setup", result);
293
+ printSetupTtyHint(result);
294
+ }
295
+ else {
296
+ // Interactive wizard
297
+ const { runSetupWizard } = await import("./setup/setup");
298
+ await runSetupWizard({ dir: args.dir, noInit });
299
+ }
120
300
  });
121
301
  },
122
302
  });
@@ -142,16 +322,33 @@ const indexCommand = defineCommand({
142
322
  meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
143
323
  args: {
144
324
  full: { type: "boolean", description: "Force full reindex", default: false },
145
- enrich: { type: "boolean", description: "Enable LLM inference and enrichment passes", default: false },
146
325
  verbose: { type: "boolean", description: "Print phase-by-phase indexing progress to stderr", default: false },
326
+ clean: {
327
+ type: "boolean",
328
+ description: "After indexing, remove any entries whose source file no longer exists on disk.",
329
+ default: false,
330
+ },
331
+ "dry-run": {
332
+ type: "boolean",
333
+ description: "When combined with --clean, report stale entries without deleting them.",
334
+ default: false,
335
+ },
147
336
  },
148
337
  async run({ args }) {
149
338
  await runWithJsonErrors(async () => {
339
+ if (getHyphenatedBoolean(args, "enrich") || parseFlagValue(process.argv, "--enrich") !== undefined) {
340
+ throw new UsageError("`akm index --enrich` has been removed. Plain `akm index` now performs metadata enrichment by default.");
341
+ }
342
+ if (getHyphenatedBoolean(args, "re-enrich") || parseFlagValue(process.argv, "--re-enrich") !== undefined) {
343
+ throw new UsageError("`akm index --re-enrich` has been removed. Re-enrichment of index-time LLM passes is not exposed in this slice.");
344
+ }
150
345
  const outputMode = getOutputMode();
151
346
  const controller = new AbortController();
152
347
  const abort = () => controller.abort(new Error("index interrupted"));
153
348
  process.once("SIGINT", abort);
154
349
  process.once("SIGTERM", abort);
350
+ const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
351
+ setLogFile(indexLogFile);
155
352
  const spin = !args.verbose && outputMode.format === "text" ? p.spinner() : null;
156
353
  if (spin) {
157
354
  spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
@@ -160,12 +357,13 @@ const indexCommand = defineCommand({
160
357
  try {
161
358
  const result = await akmIndex({
162
359
  full: args.full,
163
- enrich: args.enrich,
164
- onProgress: ({ message, processed, total }) => {
360
+ clean: args.clean,
361
+ dryRun: args["dry-run"],
362
+ onProgress: ({ phase, message, processed, total }) => {
165
363
  latestMessage = message;
166
364
  const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : "";
167
365
  if (args.verbose) {
168
- console.error(`[index] ${progressPrefix}${message}`);
366
+ info(`[index:${phase}] ${progressPrefix}${message}`);
169
367
  }
170
368
  else if (spin) {
171
369
  spin.stop(`${progressPrefix}${message}`);
@@ -186,6 +384,7 @@ const indexCommand = defineCommand({
186
384
  throw error;
187
385
  }
188
386
  finally {
387
+ clearLogFile();
189
388
  process.off("SIGINT", abort);
190
389
  process.off("SIGTERM", abort);
191
390
  }
@@ -201,6 +400,193 @@ const infoCommand = defineCommand({
201
400
  });
202
401
  },
203
402
  });
403
+ const healthCommand = defineCommand({
404
+ meta: { name: "health", description: "Check akm runtime health, artifacts, and improve metrics" },
405
+ args: {
406
+ since: {
407
+ type: "string",
408
+ description: "Rolling window start (ISO timestamp, date, epoch ms, or shorthand like 24h / 7d)",
409
+ },
410
+ },
411
+ async run({ args }) {
412
+ // Capture the health result so we can propagate its overall status into the
413
+ // process exit code AFTER the JSON envelope is flushed to stdout. The
414
+ // CHANGELOG advertises `akm health` as a CI/runtime monitoring command —
415
+ // callers rely on `akm health && deploy` style chaining, which requires
416
+ // non-zero exit on failure (and parseable JSON on stdout for diagnostics).
417
+ let resultStatus;
418
+ await runWithJsonErrors(() => {
419
+ const result = akmHealth({ since: args.since });
420
+ resultStatus = result.status;
421
+ output("health", result);
422
+ });
423
+ if (resultStatus === "fail") {
424
+ process.exit(EXIT_GENERAL);
425
+ }
426
+ if (resultStatus === "warn") {
427
+ process.exit(EXIT_HEALTH_WARN);
428
+ }
429
+ },
430
+ });
431
+ const graphCommand = defineCommand({
432
+ meta: { name: "graph", description: "Inspect the indexed entity graph stored in SQLite" },
433
+ subCommands: {
434
+ summary: defineCommand({
435
+ meta: { name: "summary", description: "Show entity-graph counts and quality telemetry" },
436
+ args: {
437
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
438
+ },
439
+ run({ args }) {
440
+ return runWithJsonErrors(() => {
441
+ output("graph-summary", akmGraphSummary({ source: args.source }));
442
+ });
443
+ },
444
+ }),
445
+ entities: defineCommand({
446
+ meta: { name: "entities", description: "List entities with per-file occurrence counts" },
447
+ args: {
448
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
449
+ limit: { type: "string", description: "Maximum entities to return" },
450
+ },
451
+ run({ args }) {
452
+ return runWithJsonErrors(() => {
453
+ output("graph-entities", akmGraphEntities({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
454
+ });
455
+ },
456
+ }),
457
+ relations: defineCommand({
458
+ meta: { name: "relations", description: "List relations with occurrence counts" },
459
+ args: {
460
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
461
+ limit: { type: "string", description: "Maximum relations to return" },
462
+ },
463
+ run({ args }) {
464
+ return runWithJsonErrors(() => {
465
+ output("graph-relations", akmGraphRelations({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
466
+ });
467
+ },
468
+ }),
469
+ related: defineCommand({
470
+ meta: { name: "related", description: "Show graph-related neighboring assets for a ref" },
471
+ args: {
472
+ ref: { type: "positional", description: "Asset ref", required: true },
473
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
474
+ limit: { type: "string", description: "Maximum related assets to return" },
475
+ },
476
+ async run({ args }) {
477
+ return runWithJsonErrors(async () => {
478
+ output("graph-related", await akmGraphRelated({
479
+ ref: args.ref ?? "",
480
+ source: args.source,
481
+ limit: parsePositiveIntFlag(args.limit ?? undefined),
482
+ }));
483
+ });
484
+ },
485
+ }),
486
+ entity: defineCommand({
487
+ meta: { name: "entity", description: "List assets that contain the given entity" },
488
+ args: {
489
+ name: { type: "positional", description: "Entity name", required: true },
490
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
491
+ limit: { type: "string", description: "Maximum matches to return" },
492
+ },
493
+ run({ args }) {
494
+ return runWithJsonErrors(() => {
495
+ output("graph-entity", akmGraphEntity({
496
+ name: args.name ?? "",
497
+ source: args.source,
498
+ limit: parsePositiveIntFlag(args.limit ?? undefined),
499
+ }));
500
+ });
501
+ },
502
+ }),
503
+ orphans: defineCommand({
504
+ meta: { name: "orphans", description: "List assets with no extracted graph entities" },
505
+ args: {
506
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
507
+ limit: { type: "string", description: "Maximum orphans to return" },
508
+ },
509
+ run({ args }) {
510
+ return runWithJsonErrors(() => {
511
+ output("graph-orphans", akmGraphOrphans({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
512
+ });
513
+ },
514
+ }),
515
+ export: defineCommand({
516
+ meta: { name: "export", description: "Export graph artifact as JSON or JSONL" },
517
+ args: {
518
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
519
+ out: { type: "string", description: "Output path" },
520
+ format: { type: "string", description: "Export format (json|jsonl)", default: "json" },
521
+ },
522
+ run({ args }) {
523
+ return runWithJsonErrors(() => {
524
+ output("graph-export", akmGraphExport({
525
+ source: args.source,
526
+ out: args.out ?? "",
527
+ format: args.format,
528
+ }));
529
+ });
530
+ },
531
+ }),
532
+ update: defineCommand({
533
+ meta: { name: "update", description: "Re-run graph extraction, optionally scoped to specific asset refs" },
534
+ args: {
535
+ refs: {
536
+ type: "positional",
537
+ description: "Zero or more asset refs to scope extraction (omit for a full re-extract)",
538
+ required: false,
539
+ default: "",
540
+ },
541
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
542
+ },
543
+ async run({ args }) {
544
+ return runWithJsonErrors(async () => {
545
+ // `refs` is a single positional; collect remaining argv tokens as well.
546
+ const rawRefs = [args.refs, ...(Array.isArray(args._) ? args._ : [])].filter((r) => typeof r === "string" && r.trim().length > 0);
547
+ output("graph-update", await akmGraphUpdate({ refs: rawRefs.length > 0 ? rawRefs : undefined, source: args.source }));
548
+ });
549
+ },
550
+ }),
551
+ },
552
+ run({ args }) {
553
+ return runWithJsonErrors(() => {
554
+ if (hasSubcommand(args, GRAPH_SUBCOMMAND_SET))
555
+ return;
556
+ output("graph-summary", akmGraphSummary());
557
+ });
558
+ },
559
+ });
560
+ // MVP DB administration. Currently only `akm db backups`; restore is manual —
561
+ // stop akm and run `scripts/migrations/restore-data-dir.sh <backup>`.
562
+ const DB_SUBCOMMAND_SET = new Set(["backups"]);
563
+ const dbCommand = defineCommand({
564
+ meta: {
565
+ name: "db",
566
+ description: "Inspect the AKM SQLite data directory. Currently exposes `backups`; to restore from a snapshot, stop akm and run scripts/migrations/restore-data-dir.sh against the chosen backup.",
567
+ },
568
+ subCommands: {
569
+ backups: defineCommand({
570
+ meta: {
571
+ name: "backups",
572
+ description: "List pre-upgrade snapshots of the data directory (newest first). Backups are created automatically before destructive DB version upgrades unless AKM_DB_BACKUP=0.",
573
+ },
574
+ run() {
575
+ return runWithJsonErrors(() => {
576
+ output("db-backups", akmDbBackups());
577
+ });
578
+ },
579
+ }),
580
+ },
581
+ run({ args }) {
582
+ return runWithJsonErrors(() => {
583
+ if (hasSubcommand(args, DB_SUBCOMMAND_SET))
584
+ return;
585
+ // Default action: list backups.
586
+ output("db-backups", akmDbBackups());
587
+ });
588
+ },
589
+ });
204
590
  const searchCommand = defineCommand({
205
591
  meta: { name: "search", description: "Search the stash" },
206
592
  args: {
@@ -220,29 +606,49 @@ const searchCommand = defineCommand({
220
606
  description: 'Include entries with quality:"proposed" in the result set. Excluded by default (v1 spec §4.2).',
221
607
  default: false,
222
608
  },
609
+ belief: {
610
+ type: "string",
611
+ description: "Memory belief filter: all|current|historical. current keeps active memory beliefs; historical keeps contradicted/superseded/archived memory beliefs.",
612
+ default: "all",
613
+ },
223
614
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
224
615
  detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)" },
616
+ "no-project-context": {
617
+ type: "boolean",
618
+ description: "Disable the automatic project-context ranking boost (also disabled by AKM_DISABLE_PROJECT_CONTEXT=1).",
619
+ default: false,
620
+ },
225
621
  },
226
622
  async run({ args }) {
227
623
  await runWithJsonErrors(async () => {
228
- // An empty query enumerates all indexed assets (list mode).
229
- // The guard that rejected empty queries was removed; akmSearch handles
230
- // empty strings end-to-end via getAllEntries (DB path) and the
231
- // substring-search fallback's query-less branch.
232
624
  const query = (args.query ?? "").trim();
233
- const type = args.type;
234
- const limitRaw = args.limit ? parseInt(args.limit, 10) : undefined;
235
- if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
236
- throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
625
+ if (!query) {
626
+ throw new UsageError('A search query is required. Usage: akm search "<query>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Pass a query like `akm search "docker"` or `akm search "code review" --type skill`.');
237
627
  }
238
- const limit = limitRaw;
628
+ const type = args.type;
629
+ const limit = parsePositiveIntFlag(args.limit ?? undefined);
239
630
  const source = parseSearchSource(args.source);
240
631
  // Repeatable; citty exposes only the last `--filter` value, so read all
241
632
  // occurrences directly from argv (same pattern as `--tag`).
242
633
  const filterTokens = parseAllFlagValues("--filter");
243
634
  const filters = parseScopeFilterFlags(filterTokens, "--filter");
244
635
  const includeProposed = args["include-proposed"] === true;
245
- const result = await akmSearch({ query, type, limit, source, filters, includeProposed });
636
+ const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
637
+ const noProjectContext = getHyphenatedBoolean(args, "no-project-context");
638
+ // --no-project-context sets env so searchDatabase picks it up without
639
+ // threading the flag through the entire call stack.
640
+ if (noProjectContext)
641
+ process.env.AKM_DISABLE_PROJECT_CONTEXT = "1";
642
+ const result = await akmSearch({
643
+ query,
644
+ type,
645
+ limit,
646
+ source,
647
+ filters,
648
+ includeProposed,
649
+ belief,
650
+ eventSource: resolveEventSource(),
651
+ });
246
652
  output("search", result);
247
653
  });
248
654
  },
@@ -267,11 +673,8 @@ const curateCommand = defineCommand({
267
673
  throw new UsageError('A curate query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Describe the task you want assets for, e.g. `akm curate "deploy to prod"`.');
268
674
  }
269
675
  const type = args.type;
270
- const limitRaw = args.limit ? parseInt(args.limit, 10) : undefined;
271
- if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
272
- throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
273
- }
274
- const limit = limitRaw && limitRaw > 0 ? limitRaw : 4;
676
+ const limitParsed = parsePositiveIntFlag(args.limit ?? undefined);
677
+ const limit = limitParsed && limitParsed > 0 ? limitParsed : 4;
275
678
  const source = parseSearchSource(args.source ?? "stash");
276
679
  const curated = await akmCurate({ query: args.query, type, limit, source });
277
680
  output("curate", curated);
@@ -297,11 +700,6 @@ const addCommand = defineCommand({
297
700
  description: "Mark a git stash as writable so changes can be pushed back",
298
701
  default: false,
299
702
  },
300
- trust: {
301
- type: "boolean",
302
- description: "Bypass install-audit blocking for this add invocation only",
303
- default: false,
304
- },
305
703
  type: {
306
704
  type: "string",
307
705
  description: "Override asset type for all files in this stash (currently supports: wiki)",
@@ -310,7 +708,7 @@ const addCommand = defineCommand({
310
708
  "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
311
709
  "allow-insecure": {
312
710
  type: "boolean",
313
- description: "Allow a plain HTTP source URL (otherwise rejected for non-localhost hosts)",
711
+ description: "Allow a plain HTTP source URL and skip confirmation for dangerous vault keys (e.g. LD_PRELOAD, PATH). Use only after explicitly reviewing the stash.",
314
712
  default: false,
315
713
  },
316
714
  },
@@ -318,6 +716,7 @@ const addCommand = defineCommand({
318
716
  await runWithJsonErrors(async () => {
319
717
  const ref = args.ref.trim();
320
718
  const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
719
+ const allowDangerousKeys = allowInsecure;
321
720
  // URL with --provider → stash source (remote or git provider)
322
721
  if (args.provider) {
323
722
  if (shouldWarnOnPlainHttp(ref)) {
@@ -370,7 +769,6 @@ const addCommand = defineCommand({
370
769
  ref,
371
770
  name: args.name,
372
771
  options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
373
- trustThisInstall: args.trust,
374
772
  writable: args.writable,
375
773
  });
376
774
  appendEvent({
@@ -385,7 +783,6 @@ const addCommand = defineCommand({
385
783
  name: args.name,
386
784
  overrideType: args.type,
387
785
  options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
388
- trustThisInstall: args.trust,
389
786
  writable: args.writable,
390
787
  });
391
788
  appendEvent({
@@ -397,6 +794,120 @@ const addCommand = defineCommand({
397
794
  writable: args.writable === true,
398
795
  },
399
796
  });
797
+ // ── Post-install vault key audit ────────────────────────────────────────
798
+ // Resolve the stash root from the install result and scan any vault files
799
+ // for dangerous env var keys. When findings are present the install is
800
+ // gated: TTY → interactive confirmation prompt; non-TTY without
801
+ // --allow-insecure → hard failure (exit 1). Pass
802
+ // --allow-insecure to skip the prompt non-interactively.
803
+ try {
804
+ const installedStashRoot = result.installed?.stashRoot ??
805
+ (result.sourceAdded && "stashRoot" in result.sourceAdded ? result.sourceAdded.stashRoot : undefined);
806
+ if (installedStashRoot) {
807
+ const { checkVaultForDangerousKeys } = await import("./commands/lint/vault-key-rules.js");
808
+ const vaultsDir = path.join(installedStashRoot, "vaults");
809
+ if (fs.existsSync(vaultsDir)) {
810
+ const envFiles = fs.readdirSync(vaultsDir).filter((f) => f.endsWith(".env"));
811
+ // Collect all dangerous-key findings across every vault file.
812
+ const allFindings = [];
813
+ for (const envFile of envFiles) {
814
+ const vaultPath = path.join(vaultsDir, envFile);
815
+ const baseName = path.basename(envFile, ".env");
816
+ const vaultRef = baseName === "" ? "vault:default" : `vault:${baseName}`;
817
+ const relPath = path.join("vaults", envFile);
818
+ const findings = checkVaultForDangerousKeys(vaultPath, relPath, vaultRef);
819
+ for (const finding of findings) {
820
+ // Extract the key name from the detail string for the summary line.
821
+ const keyMatch = finding.detail.match(/Vault key `([^`]+)`/);
822
+ const keyName = keyMatch ? keyMatch[1] : finding.file;
823
+ allFindings.push({ vaultRef, keyName, relPath });
824
+ }
825
+ }
826
+ if (allFindings.length > 0) {
827
+ if (allowDangerousKeys) {
828
+ // Operator has explicitly accepted the risk — warn and continue.
829
+ for (const f of allFindings) {
830
+ warn(`[dangerous-vault-key] ${f.relPath}: key \`${f.keyName}\` in ${f.vaultRef} can hijack process execution via \`akm vault run\`. Proceeding because --allow-insecure was set.`);
831
+ }
832
+ }
833
+ else if (process.stdin.isTTY) {
834
+ // Interactive path: show findings and ask the user to confirm.
835
+ // Guard on stdin (not stdout) because p.confirm() reads from stdin;
836
+ // stdout may be a TTY while stdin is piped, which would cause a hang.
837
+ const stashLabel = ref;
838
+ const groupedByVault = new Map();
839
+ for (const f of allFindings) {
840
+ const existing = groupedByVault.get(f.vaultRef) ?? [];
841
+ existing.push(f.keyName);
842
+ groupedByVault.set(f.vaultRef, existing);
843
+ }
844
+ for (const [vaultRef, keys] of groupedByVault) {
845
+ warn(`[warn] Vault "${vaultRef}" in stash "${stashLabel}" contains potentially dangerous keys:`);
846
+ for (const key of keys) {
847
+ warn(` - ${key}: can hijack process execution via \`akm vault run\``);
848
+ }
849
+ }
850
+ const confirmed = await p.confirm({
851
+ message: "Install anyway?",
852
+ initialValue: false,
853
+ });
854
+ if (p.isCancel(confirmed) || confirmed !== true) {
855
+ // Roll back the install before aborting.
856
+ // Use the canonical installed id (most reliably resolved by akmRemove) rather
857
+ // than the raw user-supplied ref which may not match after URL normalisation.
858
+ const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
859
+ let rollbackWarning;
860
+ try {
861
+ await akmRemove({ target: rollbackTarget });
862
+ }
863
+ catch (_rollbackErr) {
864
+ rollbackWarning =
865
+ `Rollback failed — stash may still be installed at ${installedStashRoot}. ` +
866
+ `Remove it manually with: akm remove ${rollbackTarget}`;
867
+ }
868
+ console.error(JSON.stringify({
869
+ ok: false,
870
+ error: "Install aborted: stash contains dangerous vault keys. Remove the keys or re-run with --allow-insecure to bypass.",
871
+ code: "DANGEROUS_VAULT_KEY",
872
+ ...(rollbackWarning ? { rollbackWarning } : {}),
873
+ }, null, 2));
874
+ process.exit(1);
875
+ }
876
+ }
877
+ else {
878
+ // Non-interactive path without bypass flag: fail hard.
879
+ // Roll back the install before exiting.
880
+ // Use the canonical installed id (most reliably resolved by akmRemove) rather
881
+ // than the raw user-supplied ref which may not match after URL normalisation.
882
+ const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
883
+ let rollbackWarning;
884
+ try {
885
+ await akmRemove({ target: rollbackTarget });
886
+ }
887
+ catch (_rollbackErr) {
888
+ rollbackWarning =
889
+ `Rollback failed — stash may still be installed at ${installedStashRoot}. ` +
890
+ `Remove it manually with: akm remove ${rollbackTarget}`;
891
+ }
892
+ const keyList = allFindings.map((f) => ` - ${f.keyName} (${f.vaultRef})`).join("\n");
893
+ console.error(JSON.stringify({
894
+ ok: false,
895
+ error: `Install blocked: stash "${ref}" contains dangerous vault keys that can hijack process execution via \`akm vault run\`:\n${keyList}\nRe-run with --allow-insecure to bypass this check after reviewing the vault.`,
896
+ code: "DANGEROUS_VAULT_KEY",
897
+ ...(rollbackWarning ? { rollbackWarning } : {}),
898
+ }, null, 2));
899
+ process.exit(1);
900
+ }
901
+ }
902
+ }
903
+ }
904
+ }
905
+ catch (auditErr) {
906
+ // Only swallow errors that are NOT our intentional process.exit calls.
907
+ if (auditErr instanceof Error && auditErr.message === "process.exit called")
908
+ throw auditErr;
909
+ // Vault key audit is best-effort; never fail the install on unexpected audit errors.
910
+ }
400
911
  output("add", result);
401
912
  });
402
913
  },
@@ -454,9 +965,18 @@ const removeCommand = defineCommand({
454
965
  meta: { name: "remove", description: "Remove a source by id, ref, path, URL, or name" },
455
966
  args: {
456
967
  target: { type: "positional", description: "Source to remove (id, ref, path, URL, or name)", required: true },
968
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
457
969
  },
458
970
  async run({ args }) {
459
971
  await runWithJsonErrors(async () => {
972
+ const { confirmDestructive } = await import("./cli/confirm.js");
973
+ const confirmed = await confirmDestructive(`Remove source "${args.target}"? This cannot be undone.`, {
974
+ yes: args.yes === true,
975
+ });
976
+ if (!confirmed) {
977
+ process.stderr.write("Aborted.\n");
978
+ return;
979
+ }
460
980
  const result = await akmRemove({ target: args.target });
461
981
  appendEvent({
462
982
  eventType: "remove",
@@ -545,15 +1065,17 @@ const showCommand = defineCommand({
545
1065
  },
546
1066
  async run({ args }) {
547
1067
  await runWithJsonErrors(async () => {
548
- try {
549
- parseAssetRef(args.ref);
550
- }
551
- catch (error) {
552
- if (error instanceof UsageError && error.code === "MISSING_REQUIRED_ARGUMENT") {
553
- throw new UsageError(error.message, "INVALID_FLAG_VALUE", error.hint());
1068
+ const subcommand = Array.isArray(args._) ? args._[0] : undefined;
1069
+ if (subcommand === "proposal") {
1070
+ const proposalId = Array.isArray(args._) ? args._[1] : undefined;
1071
+ if (typeof proposalId !== "string" || !proposalId.trim()) {
1072
+ throw new UsageError("Usage: akm show proposal <id>", "MISSING_REQUIRED_ARGUMENT");
554
1073
  }
555
- throw error;
1074
+ const result = akmProposalShow({ id: proposalId.trim() });
1075
+ output("proposal-show", result);
1076
+ return;
556
1077
  }
1078
+ parseAssetRef(args.ref);
557
1079
  // The knowledge-view positional syntax (`akm show knowledge:foo section "Auth"`)
558
1080
  // is rewritten to `--akmView` / `--akmHeading` / `--akmStart` / `--akmEnd`
559
1081
  // by `normalizeShowArgv` before citty parses argv. We read those values
@@ -592,7 +1114,13 @@ const showCommand = defineCommand({
592
1114
  // every occurrence directly from argv (same pattern as `--filter`).
593
1115
  const scopeTokens = parseAllFlagValues("--scope");
594
1116
  const scope = parseScopeFilterFlags(scopeTokens, "--scope");
595
- const result = await akmShowUnified({ ref: args.ref, view, detail: showDetail, scope });
1117
+ const result = await akmShowUnified({
1118
+ ref: args.ref,
1119
+ view,
1120
+ detail: showDetail,
1121
+ scope,
1122
+ eventSource: resolveEventSource(),
1123
+ });
596
1124
  output("show", result);
597
1125
  });
598
1126
  },
@@ -642,6 +1170,14 @@ const configCommand = defineCommand({
642
1170
  });
643
1171
  },
644
1172
  }),
1173
+ show: defineCommand({
1174
+ meta: { name: "show", description: "Alias for `akm config list` — list current configuration" },
1175
+ run() {
1176
+ return runWithJsonErrors(() => {
1177
+ output("config", listConfig(loadConfig()));
1178
+ });
1179
+ },
1180
+ }),
645
1181
  get: defineCommand({
646
1182
  meta: { name: "get", description: "Get a configuration value by key" },
647
1183
  args: {
@@ -658,12 +1194,36 @@ const configCommand = defineCommand({
658
1194
  args: {
659
1195
  key: { type: "positional", required: true, description: "Config key (for example: embedding, llm)" },
660
1196
  value: { type: "positional", required: true, description: "Config value" },
1197
+ // #463: stable machine-friendly entry point for plugins / hooks.
1198
+ // `--silent` suppresses the config dump on stdout so hook-driven
1199
+ // writes don't pollute their host's output stream.
1200
+ silent: {
1201
+ type: "boolean",
1202
+ description: "Suppress the post-write config dump on stdout. Use from hooks and CI scripts; the write still happens and errors still print.",
1203
+ default: false,
1204
+ },
1205
+ // #463: explicit layer flag for forward-compat. User layer is the only
1206
+ // settable layer today; the flag exists so plugin authors can encode
1207
+ // intent and the surface stays stable if project-layer writes return.
1208
+ layer: {
1209
+ type: "string",
1210
+ description: "Config layer to write to. Currently only `user` is supported.",
1211
+ default: "user",
1212
+ },
661
1213
  },
662
1214
  run({ args }) {
663
1215
  return runWithJsonErrors(() => {
664
- const updated = setConfigValue(loadUserConfig(), args.key, args.value);
1216
+ if (args.layer && args.layer !== "user") {
1217
+ throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1218
+ }
1219
+ // Use loadConfig (not loadUserConfig) so the project-config
1220
+ // deprecation warning fires consistently with `akm config get`
1221
+ // (#457). Effective merged shape is identical post-0.8.0.
1222
+ const updated = setConfigValue(loadConfig(), args.key, args.value);
665
1223
  saveConfig(updated);
666
- output("config", listConfig(updated));
1224
+ if (!args.silent) {
1225
+ output("config", listConfig(updated));
1226
+ }
667
1227
  });
668
1228
  },
669
1229
  }),
@@ -671,19 +1231,66 @@ const configCommand = defineCommand({
671
1231
  meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/llm section" },
672
1232
  args: {
673
1233
  key: { type: "positional", required: true, description: "Config key to unset" },
1234
+ silent: {
1235
+ type: "boolean",
1236
+ description: "Suppress the post-write config dump on stdout.",
1237
+ default: false,
1238
+ },
1239
+ layer: {
1240
+ type: "string",
1241
+ description: "Config layer to write to. Currently only `user` is supported.",
1242
+ default: "user",
1243
+ },
674
1244
  },
675
1245
  run({ args }) {
676
1246
  return runWithJsonErrors(() => {
677
- const updated = unsetConfigValue(loadUserConfig(), args.key);
1247
+ if (args.layer && args.layer !== "user") {
1248
+ throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1249
+ }
1250
+ const updated = unsetConfigValue(loadConfig(), args.key);
678
1251
  saveConfig(updated);
679
- output("config", listConfig(updated));
1252
+ if (!args.silent) {
1253
+ output("config", listConfig(updated));
1254
+ }
1255
+ });
1256
+ },
1257
+ }),
1258
+ validate: defineCommand({
1259
+ meta: {
1260
+ name: "validate",
1261
+ description: "Validate the on-disk config file against the schema. Exits non-zero on errors.",
1262
+ },
1263
+ async run() {
1264
+ return runWithJsonErrors(async () => {
1265
+ const { runConfigValidate } = await import("./cli/config-validate.js");
1266
+ await runConfigValidate();
1267
+ });
1268
+ },
1269
+ }),
1270
+ migrate: defineCommand({
1271
+ meta: {
1272
+ name: "migrate",
1273
+ description: "Migrate the config file to the current schema version. Use --dry-run to preview without writing.",
1274
+ },
1275
+ args: {
1276
+ "dry-run": { type: "boolean", description: "Preview the migration result without writing.", default: false },
1277
+ "print-diff": {
1278
+ type: "boolean",
1279
+ description: "Print a unified diff of old vs new config alongside the migration output.",
1280
+ default: false,
1281
+ },
1282
+ },
1283
+ async run({ args }) {
1284
+ return runWithJsonErrors(async () => {
1285
+ const { runConfigMigrate } = await import("./cli/config-migrate.js");
1286
+ await runConfigMigrate({ dryRun: Boolean(args["dry-run"]), printDiff: Boolean(args["print-diff"]) });
680
1287
  });
681
1288
  },
682
1289
  }),
683
1290
  },
684
1291
  run({ args }) {
685
1292
  return runWithJsonErrors(() => {
686
- if (hasConfigSubcommand(args))
1293
+ if (hasSubcommand(args, CONFIG_SUBCOMMAND_SET))
687
1294
  return;
688
1295
  if (args.list) {
689
1296
  output("config", listConfig(loadConfig()));
@@ -903,10 +1510,7 @@ const registryCommand = defineCommand({
903
1510
  },
904
1511
  async run({ args }) {
905
1512
  await runWithJsonErrors(async () => {
906
- const limitRaw = args.limit ? parseInt(args.limit, 10) : undefined;
907
- if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
908
- throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
909
- }
1513
+ const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
910
1514
  const result = await searchRegistry(args.query, { limit: limitRaw, includeAssets: args.assets });
911
1515
  output("registry-search", result);
912
1516
  });
@@ -971,7 +1575,7 @@ const feedbackCommand = defineCommand({
971
1575
  description: "Record positive or negative feedback for any indexed stash asset.\n\n" +
972
1576
  "Positive feedback boosts an asset's EMA utility score, making it rank higher\n" +
973
1577
  "in future searches without requiring a full reindex.\n\n" +
974
- "Negative feedback records a negative signal in usage_events and events.jsonl.\n" +
1578
+ "Negative feedback records a negative signal in usage_events and state.db events.\n" +
975
1579
  "It does NOT immediately lower the asset's ranking — the EMA utility score is\n" +
976
1580
  "updated the next time `akm index` runs (incremental or full). Run `akm index`\n" +
977
1581
  "after recording negative feedback to have it reflected in search results.",
@@ -988,11 +1592,27 @@ const feedbackCommand = defineCommand({
988
1592
  "Reindexing is required for the signal to affect search results.",
989
1593
  default: false,
990
1594
  },
991
- note: { type: "string", description: "Optional note to attach to the feedback" },
1595
+ reason: {
1596
+ type: "string",
1597
+ description: "Reason for the feedback (required for negative feedback by default; used by distillation)",
1598
+ },
1599
+ note: { type: "string", description: "Alias for --reason (backward-compatible, prefer --reason)" },
1600
+ "failure-mode": {
1601
+ type: "string",
1602
+ description: `Structured failure-mode taxonomy for negative feedback (F-3 / #384). ` +
1603
+ `Accepted values: ${FEEDBACK_FAILURE_MODES.join(", ")}. ` +
1604
+ "Stored alongside --reason in event metadata for aggregation by the distill pipeline.",
1605
+ },
992
1606
  tag: {
993
1607
  type: "string",
994
1608
  description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
995
1609
  },
1610
+ "applied-to": {
1611
+ type: "string",
1612
+ description: "Credit a lesson that helped resolve this task. Accepts a `lesson:<name>` ref. " +
1613
+ "When combined with --positive, appends this feedback ref to the target lesson's " +
1614
+ "`lessonStrength[]` frontmatter array (dedup, idempotent). Ignored on non-lesson targets.",
1615
+ },
996
1616
  },
997
1617
  run({ args }) {
998
1618
  return runWithJsonErrors(async () => {
@@ -1008,11 +1628,39 @@ const feedbackCommand = defineCommand({
1008
1628
  throw new UsageError("Specify --positive or --negative.");
1009
1629
  }
1010
1630
  const signal = args.positive ? "positive" : "negative";
1631
+ const reason = args.reason ?? args.note;
1632
+ // F-3 / #384: Validate --failure-mode against the curated enum.
1633
+ const failureMode = args["failure-mode"]?.trim() || undefined;
1634
+ if (failureMode) {
1635
+ if (args.positive) {
1636
+ throw new UsageError("--failure-mode is only valid for negative feedback.", "INVALID_FLAG_VALUE", "Remove --failure-mode or switch to --negative.");
1637
+ }
1638
+ const cfg = loadConfig();
1639
+ const allowedModes = cfg.feedback?.allowedFailureModes ?? FEEDBACK_FAILURE_MODES;
1640
+ if (allowedModes.length > 0 && !allowedModes.includes(failureMode)) {
1641
+ throw new UsageError(`Invalid --failure-mode "${failureMode}". Accepted values: ${allowedModes.join(", ")}.`, "INVALID_FLAG_VALUE", `Use one of: ${allowedModes.join(", ")}`);
1642
+ }
1643
+ }
1644
+ if (args.negative === true && !reason?.trim()) {
1645
+ // F-3 / #384: Default requireReason is now true. Load config to allow
1646
+ // operators to opt out via feedback.requireReason: false in akm.json.
1647
+ const cfg = loadConfig();
1648
+ const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
1649
+ if (requireReason) {
1650
+ throw new UsageError("Negative feedback requires --reason (structured failure signals are needed for distillation). " +
1651
+ "Use --failure-mode for a curated taxonomy or --reason for free text. " +
1652
+ "Set feedback.requireReason: false in akm.json to downgrade to a warning.", "MISSING_REQUIRED_ARGUMENT", `Hint: akm feedback ${ref} --negative --reason "..." [--failure-mode incorrect|outdated|dangerous|incomplete|redundant]`);
1653
+ }
1654
+ else {
1655
+ warn("Warning: negative feedback without --reason provides less distillation signal.");
1656
+ }
1657
+ }
1011
1658
  const rawTags = parseAllFlagValues("--tag");
1012
1659
  const validatedTags = validateFeedbackTags(rawTags);
1013
1660
  const metadataObj = {
1014
1661
  signal,
1015
- ...(args.note ? { note: args.note } : {}),
1662
+ ...(reason?.trim() ? { reason: reason.trim() } : {}),
1663
+ ...(failureMode ? { failureMode } : {}),
1016
1664
  ...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
1017
1665
  };
1018
1666
  const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
@@ -1021,6 +1669,7 @@ const feedbackCommand = defineCommand({
1021
1669
  if (sources.length > 0) {
1022
1670
  await ensureIndex(sources[0].path);
1023
1671
  }
1672
+ let utilityResult;
1024
1673
  const db = openExistingDatabase();
1025
1674
  try {
1026
1675
  const entryId = findEntryIdByRef(db, ref);
@@ -1040,6 +1689,26 @@ const feedbackCommand = defineCommand({
1040
1689
  signal,
1041
1690
  metadata: metadataStr,
1042
1691
  });
1692
+ // Apply feedback-derived utility score adjustment immediately so that
1693
+ // positive/negative signals influence search ranking without requiring
1694
+ // a full reindex. We query the total accumulated feedback counts from
1695
+ // usage_events so the delta reflects the entire signal history.
1696
+ // Uses MemRL bounded-step EMA (F-5 / #386, arXiv:2601.03192).
1697
+ try {
1698
+ const counts = db
1699
+ .prepare(`SELECT
1700
+ SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
1701
+ SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
1702
+ FROM usage_events
1703
+ WHERE event_type = 'feedback' AND entry_id = ?`)
1704
+ .get(entryId);
1705
+ const pos = counts?.pos ?? 0;
1706
+ const neg = counts?.neg ?? 0;
1707
+ utilityResult = applyFeedbackToUtilityScore(db, entryId, pos, neg);
1708
+ }
1709
+ catch {
1710
+ // best-effort — feedback recording succeeds even if utility update fails
1711
+ }
1043
1712
  }
1044
1713
  finally {
1045
1714
  closeDatabase(db);
@@ -1049,129 +1718,187 @@ const feedbackCommand = defineCommand({
1049
1718
  ref,
1050
1719
  metadata: metadataObj,
1051
1720
  });
1052
- output("feedback", { ok: true, ref, signal, note: args.note ?? null, tags: validatedTags });
1053
- });
1054
- },
1055
- });
1056
- const historyCommand = defineCommand({
1057
- meta: {
1058
- name: "history",
1059
- description: "Show mutation/usage history for a single asset (--ref) or stash-wide.\n\n" +
1060
- "Event sources:\n" +
1721
+ // F-5 / #386: When a high-utility asset crosses below the review threshold,
1722
+ // auto-create a review-needed escalation proposal so a human can confirm
1723
+ // whether the negative feedback is valid before the asset falls out of
1724
+ // the improve loop. Best-effort — failure is logged but does not fail the
1725
+ // feedback command.
1726
+ // Emit a structured event rather than a proposal so the review-needed
1727
+ // signal is queryable via `akm events list --type improve_review_needed`
1728
+ // without risking accidental asset overwrite if the proposal is accepted.
1729
+ if (utilityResult?.crossedReviewThreshold) {
1730
+ try {
1731
+ appendEvent({
1732
+ eventType: "improve_review_needed",
1733
+ ref,
1734
+ metadata: {
1735
+ previousUtility: utilityResult.previousUtility,
1736
+ nextUtility: utilityResult.nextUtility,
1737
+ reason: reason?.trim() ?? null,
1738
+ failureMode: failureMode ?? null,
1739
+ },
1740
+ });
1741
+ }
1742
+ catch (escalationErr) {
1743
+ warn(`[feedback] Could not emit review-needed event for ${ref}: ${escalationErr instanceof Error ? escalationErr.message : String(escalationErr)}`);
1744
+ }
1745
+ }
1746
+ // Phase 7A / Advantage D4b: --applied-to credits a lesson. When the
1747
+ // target is a `lesson:<name>` ref and the signal is positive, append
1748
+ // the feedback ref to the target lesson's `lessonStrength[]`
1749
+ // frontmatter array (dedup, idempotent). Non-lesson targets are
1750
+ // ignored. Failures here are warnings — feedback recording is the
1751
+ // primary contract and must not regress on lesson-write errors.
1752
+ const appliedToRaw = args["applied-to"]?.trim();
1753
+ let appliedToResult = null;
1754
+ if (appliedToRaw && signal === "positive") {
1755
+ try {
1756
+ const parsedApplied = parseAssetRef(appliedToRaw);
1757
+ if (parsedApplied.type === "lesson") {
1758
+ const updated = appendLessonStrength(parsedApplied.type, parsedApplied.name, ref);
1759
+ if (updated) {
1760
+ appliedToResult = { lessonRef: appliedToRaw, strength: updated.strength };
1761
+ }
1762
+ }
1763
+ }
1764
+ catch (err) {
1765
+ warn(`[feedback] --applied-to failed for ${appliedToRaw}: ${err instanceof Error ? err.message : String(err)}`);
1766
+ }
1767
+ }
1768
+ else if (appliedToRaw && signal !== "positive") {
1769
+ warn("[feedback] --applied-to is ignored without --positive; lesson credit is only recorded on positive signals.");
1770
+ }
1771
+ output("feedback", {
1772
+ ok: true,
1773
+ ref,
1774
+ signal,
1775
+ reason: reason?.trim() ?? null,
1776
+ failureMode: failureMode ?? null,
1777
+ tags: validatedTags,
1778
+ ...(appliedToResult
1779
+ ? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
1780
+ : {}),
1781
+ });
1782
+ });
1783
+ },
1784
+ });
1785
+ /**
1786
+ * Phase 7A: append a feedback ref to a lesson's `lessonStrength[]`
1787
+ * frontmatter array. Returns `{ strength }` (post-update count) on success,
1788
+ * or `null` when the lesson cannot be located. Idempotent: if the ref is
1789
+ * already credited, no write occurs.
1790
+ *
1791
+ * The function looks up the lesson's file via the indexer DB so the write
1792
+ * targets the canonical on-disk location. Frontmatter is rewritten in
1793
+ * place (no asset-spec round-trip) because we're modifying a single key on
1794
+ * an existing asset — the same pattern memory-inference uses for
1795
+ * `inferenceProcessed`.
1796
+ */
1797
+ function appendLessonStrength(type, name, feedbackRef) {
1798
+ const ref = `${type}:${name}`;
1799
+ let filePath;
1800
+ const db = openExistingDatabase();
1801
+ try {
1802
+ const entryId = findEntryIdByRef(db, ref);
1803
+ if (entryId === undefined) {
1804
+ warn(`[feedback] --applied-to: lesson ${ref} is not in the index.`);
1805
+ return null;
1806
+ }
1807
+ const row = db.prepare("SELECT file_path FROM entries WHERE id = ?").get(entryId);
1808
+ if (!row?.file_path) {
1809
+ warn(`[feedback] --applied-to: cannot resolve file path for ${ref}.`);
1810
+ return null;
1811
+ }
1812
+ filePath = row.file_path;
1813
+ }
1814
+ finally {
1815
+ closeDatabase(db);
1816
+ }
1817
+ if (!filePath || !fs.existsSync(filePath)) {
1818
+ warn(`[feedback] --applied-to: lesson file missing on disk for ${ref}.`);
1819
+ return null;
1820
+ }
1821
+ const raw = fs.readFileSync(filePath, "utf8");
1822
+ const parsed = parseFrontmatter(raw);
1823
+ const data = { ...parsed.data };
1824
+ const existing = data.lessonStrength;
1825
+ const strengthList = Array.isArray(existing)
1826
+ ? existing.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim())
1827
+ : typeof existing === "string" && existing.trim().length > 0
1828
+ ? [existing.trim()]
1829
+ : [];
1830
+ if (strengthList.includes(feedbackRef)) {
1831
+ // Already credited — idempotent no-op.
1832
+ return { strength: strengthList.length };
1833
+ }
1834
+ strengthList.push(feedbackRef);
1835
+ data.lessonStrength = strengthList;
1836
+ const block = parseFrontmatterBlock(raw);
1837
+ const body = block?.content ?? raw;
1838
+ const next = assembleAsset(data, body);
1839
+ try {
1840
+ // Preserve the existing file's permission bits (markdown assets are
1841
+ // typically 0o644); writeFileAtomic defaults to 0o600 otherwise.
1842
+ const mode = fs.statSync(filePath).mode & 0o777;
1843
+ writeFileAtomic(filePath, next, mode);
1844
+ }
1845
+ catch (err) {
1846
+ warn(`[feedback] --applied-to: failed to write ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
1847
+ return null;
1848
+ }
1849
+ return { strength: strengthList.length };
1850
+ }
1851
+ const historyCommand = defineCommand({
1852
+ meta: {
1853
+ name: "history",
1854
+ description: "Show mutation/usage history for a single asset (--ref) or stash-wide.\n\n" +
1855
+ "Event sources:\n" +
1061
1856
  " usage_events (default): search, show, and feedback events from the local index.\n" +
1062
- " events.jsonl (--include-proposals): proposal lifecycle events (promoted, rejected)\n" +
1063
- " emitted by `akm proposal accept` / `akm proposal reject`.\n\n" +
1857
+ " state.db events (--include-proposals): proposal lifecycle events (promoted, rejected)\n" +
1858
+ " emitted by `akm accept` / `akm reject`.\n\n" +
1064
1859
  "Results from all active sources are merged and sorted chronologically.",
1065
1860
  },
1066
1861
  args: {
1067
1862
  ref: { type: "string", description: "Asset ref (type:name). Omit for stash-wide history." },
1068
1863
  since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
1864
+ source: {
1865
+ type: "string",
1866
+ description: 'Filter by event source: "user" (default) or "improve" (akm improve operations).',
1867
+ },
1069
1868
  "include-proposals": {
1070
1869
  type: "boolean",
1071
- description: "Also include proposal lifecycle events (promoted, rejected) from events.jsonl. " +
1870
+ description: "Also include proposal lifecycle events (promoted, rejected) from state.db events. " +
1072
1871
  "Default: false (usage_events only).",
1073
1872
  default: false,
1074
1873
  },
1874
+ "accept-rate-by-source": {
1875
+ type: "boolean",
1876
+ description: "Compute accept-rate-per-source metrics from the proposal store and include them in the output (F-4 / #385). " +
1877
+ "Useful for measuring which generators (reflect, distill, …) produce the most accepted proposals.",
1878
+ default: false,
1879
+ },
1075
1880
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
1076
1881
  },
1077
1882
  run({ args }) {
1078
1883
  return runWithJsonErrors(async () => {
1884
+ const sourceFlag = args.source;
1885
+ if (sourceFlag !== undefined && sourceFlag !== "user" && sourceFlag !== "improve") {
1886
+ throw new UsageError(`Invalid --source value: "${args.source}". Must be "user" or "improve".`, "INVALID_FLAG_VALUE");
1887
+ }
1888
+ const sources = resolveSourceEntries();
1889
+ const stashDir = sources[0]?.path;
1079
1890
  const result = await akmHistory({
1080
1891
  ref: args.ref,
1081
1892
  since: args.since,
1893
+ source: sourceFlag,
1082
1894
  includeProposals: args["include-proposals"],
1895
+ acceptRateBySource: args["accept-rate-by-source"],
1896
+ stashDir,
1083
1897
  });
1084
1898
  output("history", result);
1085
1899
  });
1086
1900
  },
1087
1901
  });
1088
- function normalizeMarkdownAssetName(name, fallback) {
1089
- const trimmed = (name ?? fallback)
1090
- .trim()
1091
- .replace(/\\/g, "/")
1092
- .replace(/^\/+|\/+$/g, "")
1093
- .replace(/\.md$/i, "");
1094
- if (!trimmed)
1095
- throw new UsageError("Asset name cannot be empty.");
1096
- const segments = trimmed.split("/");
1097
- if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
1098
- throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
1099
- }
1100
- return trimmed;
1101
- }
1102
- function slugifyAssetName(value, fallbackPrefix) {
1103
- const slug = value
1104
- .toLowerCase()
1105
- .replace(/^[#>\-\s]+/, "")
1106
- .replace(/[^a-z0-9]+/g, "-")
1107
- .replace(/^-+|-+$/g, "")
1108
- .slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
1109
- return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
1110
- }
1111
- function inferAssetName(content, fallbackPrefix, preferred) {
1112
- const firstNonEmptyLine = content
1113
- .split(/\r?\n/)
1114
- .map((line) => line.trim())
1115
- .find((line) => line.length > 0);
1116
- const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
1117
- return slugifyAssetName(basis, fallbackPrefix);
1118
- }
1119
- function readKnowledgeContent(source) {
1120
- if (source === "-") {
1121
- const content = tryReadStdinText();
1122
- if (!content?.trim()) {
1123
- throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
1124
- }
1125
- return { content };
1126
- }
1127
- const resolvedSource = path.resolve(source);
1128
- let stat;
1129
- try {
1130
- stat = fs.statSync(resolvedSource);
1131
- }
1132
- catch {
1133
- throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
1134
- }
1135
- if (!stat.isFile()) {
1136
- throw new UsageError(`Knowledge source must be a file: "${source}".`);
1137
- }
1138
- return {
1139
- content: fs.readFileSync(resolvedSource, "utf8"),
1140
- preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
1141
- };
1142
- }
1143
- async function readKnowledgeInput(source) {
1144
- if (!isHttpUrl(source))
1145
- return readKnowledgeContent(source);
1146
- const snapshot = await fetchWebsiteMarkdownSnapshot(source);
1147
- return { content: snapshot.content, preferredName: snapshot.preferredName };
1148
- }
1149
- async function writeMarkdownAsset(options) {
1150
- // Resolve write target via the v1 precedence chain (`--target` →
1151
- // `defaultWriteTarget` → working stash). Per spec §10 step 5, this is the
1152
- // single dispatch point — `core/write-source.ts` owns all kind-branching.
1153
- const cfg = loadConfig();
1154
- const { source, config } = resolveWriteTarget(cfg, options.target);
1155
- const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
1156
- const normalizedName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
1157
- // Pre-flight: existence + force semantics. The helper itself overwrites
1158
- // unconditionally; the CLI surfaces a friendlier UsageError before any
1159
- // disk activity when --force is absent.
1160
- const assetPath = resolveAssetPathFromName(options.type, typeRoot, normalizedName);
1161
- if (!isWithin(assetPath, typeRoot)) {
1162
- throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
1163
- }
1164
- if (fs.existsSync(assetPath) && !options.force) {
1165
- throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
1166
- }
1167
- // Delegate the actual write (and optional git commit/push) to the helper.
1168
- const result = await writeAssetToSource(source, config, { type: options.type, name: normalizedName }, options.content);
1169
- return {
1170
- ref: result.ref,
1171
- path: result.path,
1172
- stashDir: source.path,
1173
- };
1174
- }
1175
1902
  const workflowStartCommand = defineCommand({
1176
1903
  meta: {
1177
1904
  name: "start",
@@ -1196,16 +1923,20 @@ const workflowNextCommand = defineCommand({
1196
1923
  args: {
1197
1924
  target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
1198
1925
  params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
1926
+ "dry-run": { type: "boolean", description: "Not supported — rejected with an error", default: false },
1199
1927
  },
1200
1928
  async run({ args }) {
1201
1929
  await runWithJsonErrors(async () => {
1930
+ if (getHyphenatedBoolean(args, "dry-run")) {
1931
+ throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
1932
+ }
1202
1933
  const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
1203
1934
  // If the target looks like a UUID-style run id (no `:` and matches the
1204
1935
  // run-id shape), short-circuit with a structured WORKFLOW_NOT_FOUND
1205
1936
  // error before parseAssetRef gets to throw an unhelpful ref-parse error.
1206
1937
  if (looksLikeWorkflowRunId(args.target)) {
1207
1938
  const { hasWorkflowRun } = await import("./workflows/runs.js");
1208
- if (!hasWorkflowRun(args.target)) {
1939
+ if (!(await hasWorkflowRun(args.target))) {
1209
1940
  throw new NotFoundError(`Workflow run "${args.target}" not found.`, "WORKFLOW_NOT_FOUND", "Run `akm workflow list --active` to see runs.");
1210
1941
  }
1211
1942
  }
@@ -1250,7 +1981,7 @@ const workflowCompleteCommand = defineCommand({
1250
1981
  },
1251
1982
  async run({ args }) {
1252
1983
  await runWithJsonErrors(async () => {
1253
- const result = completeWorkflowStep({
1984
+ const result = await completeWorkflowStep({
1254
1985
  runId: args.runId,
1255
1986
  stepId: args.step,
1256
1987
  status: parseWorkflowStepState(args.state),
@@ -1270,7 +2001,7 @@ const workflowStatusCommand = defineCommand({
1270
2001
  target: { type: "positional", description: "Workflow run id or workflow ref (workflow:<name>)", required: true },
1271
2002
  },
1272
2003
  run({ args }) {
1273
- return runWithJsonErrors(() => {
2004
+ return runWithJsonErrors(async () => {
1274
2005
  const target = args.target;
1275
2006
  // Check if target looks like a workflow ref
1276
2007
  const parsed = (() => {
@@ -1283,18 +2014,18 @@ const workflowStatusCommand = defineCommand({
1283
2014
  })();
1284
2015
  if (parsed?.type === "workflow") {
1285
2016
  const ref = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
1286
- const { runs } = listWorkflowRuns({ workflowRef: ref });
2017
+ const { runs } = await listWorkflowRuns({ workflowRef: ref });
1287
2018
  if (runs.length === 0) {
1288
2019
  throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
1289
2020
  }
1290
2021
  const mostRecent = runs[0];
1291
2022
  if (!mostRecent)
1292
2023
  throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
1293
- const result = getWorkflowStatus(mostRecent.id);
2024
+ const result = await getWorkflowStatus(mostRecent.id);
1294
2025
  output("workflow-status", result);
1295
2026
  }
1296
2027
  else {
1297
- const result = getWorkflowStatus(target);
2028
+ const result = await getWorkflowStatus(target);
1298
2029
  output("workflow-status", result);
1299
2030
  }
1300
2031
  });
@@ -1310,8 +2041,8 @@ const workflowListCommand = defineCommand({
1310
2041
  active: { type: "boolean", description: "Only show active runs", default: false },
1311
2042
  },
1312
2043
  run({ args }) {
1313
- return runWithJsonErrors(() => {
1314
- const result = listWorkflowRuns({ workflowRef: args.ref, activeOnly: args.active });
2044
+ return runWithJsonErrors(async () => {
2045
+ const result = await listWorkflowRuns({ workflowRef: args.ref, activeOnly: args.active });
1315
2046
  output("workflow-list", result);
1316
2047
  });
1317
2048
  },
@@ -1424,8 +2155,8 @@ const workflowResumeCommand = defineCommand({
1424
2155
  runId: { type: "positional", description: "Workflow run id", required: true },
1425
2156
  },
1426
2157
  run({ args }) {
1427
- return runWithJsonErrors(() => {
1428
- const result = resumeWorkflowRun(args.runId);
2158
+ return runWithJsonErrors(async () => {
2159
+ const result = await resumeWorkflowRun(args.runId);
1429
2160
  output("workflow-resume", result);
1430
2161
  });
1431
2162
  },
@@ -1447,10 +2178,10 @@ const workflowCommand = defineCommand({
1447
2178
  validate: workflowValidateCommand,
1448
2179
  },
1449
2180
  run({ args }) {
1450
- return runWithJsonErrors(() => {
2181
+ return runWithJsonErrors(async () => {
1451
2182
  if (hasWorkflowSubcommand(args))
1452
2183
  return;
1453
- output("workflow-list", listWorkflowRuns({ activeOnly: true }));
2184
+ output("workflow-list", await listWorkflowRuns({ activeOnly: true }));
1454
2185
  });
1455
2186
  },
1456
2187
  });
@@ -1520,6 +2251,10 @@ const rememberCommand = defineCommand({
1520
2251
  type: "string",
1521
2252
  description: "Scope this memory to a channel name (persisted as `scope_channel` frontmatter)",
1522
2253
  },
2254
+ showSimilar: {
2255
+ type: "boolean",
2256
+ description: "Return top-3 similar existing memories in output (opt-in)",
2257
+ },
1523
2258
  },
1524
2259
  async run({ args }) {
1525
2260
  return runWithJsonErrors(async () => {
@@ -1541,14 +2276,25 @@ const rememberCommand = defineCommand({
1541
2276
  if (typeof args.channel === "string" && args.channel.trim())
1542
2277
  scopeFields.channel = args.channel.trim();
1543
2278
  const hasScope = Object.keys(scopeFields).length > 0;
1544
- const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description || args.enrich;
2279
+ const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description;
1545
2280
  const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto;
1546
2281
  if (!hasStructuredArgs) {
2282
+ // Phase 1B / Rec 7: even the zero-flag hot-path emits
2283
+ // `captureMode: hot` + `beliefState: asserted` so user-supplied
2284
+ // memories outrank background-derived ones during ranking.
2285
+ const frontmatterBlock = buildMemoryFrontmatter({
2286
+ captureMode: "hot",
2287
+ beliefState: "asserted",
2288
+ });
2289
+ const contentWithFrontmatter = `${frontmatterBlock}\n${body}`;
2290
+ // Derive the asset slug from the body (not the frontmatter block);
2291
+ // otherwise inferAssetName would key off the leading `---` delimiter.
1547
2292
  const result = await writeMarkdownAsset({
1548
2293
  type: "memory",
1549
- content: body,
2294
+ content: contentWithFrontmatter,
1550
2295
  name: args.name,
1551
2296
  fallbackPrefix: "memory",
2297
+ preferredName: inferAssetName(body, "memory"),
1552
2298
  force: args.force,
1553
2299
  target: args.target,
1554
2300
  });
@@ -1557,7 +2303,13 @@ const rememberCommand = defineCommand({
1557
2303
  ref: result.ref,
1558
2304
  metadata: { path: result.path, force: args.force === true },
1559
2305
  });
1560
- output("remember", { ok: true, ...result });
2306
+ if (args.showSimilar) {
2307
+ const similar = await fetchSimilarMemories(body.slice(0, 500), result.ref);
2308
+ output("remember", { ok: true, ...result, similar });
2309
+ }
2310
+ else {
2311
+ output("remember", { ok: true, ...result });
2312
+ }
1561
2313
  return;
1562
2314
  }
1563
2315
  // ── Accumulate metadata from all three modes ──────────────────────────
@@ -1616,6 +2368,10 @@ const rememberCommand = defineCommand({
1616
2368
  "Provide them via --tag <value>, --auto (heuristics), or --enrich (LLM).");
1617
2369
  }
1618
2370
  // ── Build frontmatter and write ───────────────────────────────────────
2371
+ // Phase 1B / Rec 7: the hot-path CLI write always marks the memory as
2372
+ // `captureMode: hot` and `beliefState: asserted`. Ranking applies a
2373
+ // hot-capture boost so user-supplied memories outrank otherwise-equal
2374
+ // background-derived ones.
1619
2375
  const frontmatterBlock = buildMemoryFrontmatter({
1620
2376
  description,
1621
2377
  tags,
@@ -1623,6 +2379,8 @@ const rememberCommand = defineCommand({
1623
2379
  observed_at,
1624
2380
  expires,
1625
2381
  subjective,
2382
+ captureMode: "hot",
2383
+ beliefState: "asserted",
1626
2384
  ...(hasScope ? { scope: scopeFields } : {}),
1627
2385
  });
1628
2386
  const contentWithFrontmatter = `${frontmatterBlock}\n${body}`;
@@ -1646,53 +2404,31 @@ const rememberCommand = defineCommand({
1646
2404
  ...(hasScope ? { scope: scopeFields } : {}),
1647
2405
  },
1648
2406
  });
1649
- output("remember", { ok: true, ...result });
2407
+ if (args.showSimilar) {
2408
+ const similar = await fetchSimilarMemories((body ?? args.content ?? "").slice(0, 500), result.ref);
2409
+ output("remember", { ok: true, ...result, similar });
2410
+ }
2411
+ else {
2412
+ output("remember", { ok: true, ...result });
2413
+ }
1650
2414
  });
1651
2415
  },
1652
2416
  });
1653
- function resolveRememberContentArg(content) {
1654
- if (content === undefined)
1655
- return undefined;
1656
- const parsedFormat = parseFlagValue(process.argv, "--format");
1657
- if (parsedFormat !== undefined &&
1658
- content === parsedFormat &&
1659
- wasRememberFlagValueConsumedAsContent(content, parsedFormat, "--format")) {
1660
- return undefined;
1661
- }
1662
- const parsedDetail = parseFlagValue(process.argv, "--detail");
1663
- if (parsedDetail !== undefined &&
1664
- content === parsedDetail &&
1665
- wasRememberFlagValueConsumedAsContent(content, parsedDetail, "--detail")) {
1666
- return undefined;
2417
+ /**
2418
+ * Best-effort top-3 similar memory search for `--show-similar`.
2419
+ * Scoped to memory: type; excludes the just-written ref.
2420
+ */
2421
+ async function fetchSimilarMemories(query, excludeRef) {
2422
+ try {
2423
+ const result = await akmSearch({ query, type: "memory", limit: 4 });
2424
+ return (result.hits ?? [])
2425
+ .filter((h) => "ref" in h && h.ref !== excludeRef)
2426
+ .slice(0, 3)
2427
+ .map((h) => ({ ref: h.ref, ...(h.name ? { title: h.name } : {}) }));
1667
2428
  }
1668
- return content;
1669
- }
1670
- function wasRememberFlagValueConsumedAsContent(content, flagValue, flagName) {
1671
- const argv = process.argv.slice(2);
1672
- const rememberIndex = argv.indexOf("remember");
1673
- const tokens = rememberIndex >= 0 ? argv.slice(rememberIndex + 1) : argv;
1674
- let flagIndex = -1;
1675
- let flagConsumesNextToken = false;
1676
- for (let i = 0; i < tokens.length; i += 1) {
1677
- const token = tokens[i];
1678
- if (token === flagName) {
1679
- flagIndex = i;
1680
- flagConsumesNextToken = true;
1681
- break;
1682
- }
1683
- if (token === `${flagName}=${flagValue}`) {
1684
- flagIndex = i;
1685
- break;
1686
- }
2429
+ catch {
2430
+ return [];
1687
2431
  }
1688
- if (flagIndex === -1)
1689
- return false;
1690
- if (tokens.slice(0, flagIndex).includes(content))
1691
- return false;
1692
- const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? 2 : 1);
1693
- if (tokens.slice(firstTokenAfterFlag).includes(content))
1694
- return false;
1695
- return true;
1696
2432
  }
1697
2433
  const importKnowledgeCommand = defineCommand({
1698
2434
  meta: {
@@ -1782,10 +2518,11 @@ const helpCommand = defineCommand({
1782
2518
  },
1783
2519
  run({ args }) {
1784
2520
  return runWithJsonErrors(() => {
1785
- if (!args.version || !String(args.version).trim()) {
2521
+ const version = resolveHelpMigrateVersionArg(typeof args.version === "string" ? args.version : undefined);
2522
+ if (!version?.trim()) {
1786
2523
  throw new UsageError("Usage: akm help migrate <version>.", "MISSING_REQUIRED_ARGUMENT", "Pass a version like `0.6.0`, `v0.6.0`, `0.6.0-rc1`, or `latest`.");
1787
2524
  }
1788
- process.stdout.write(renderMigrationHelp(args.version));
2525
+ process.stdout.write(renderMigrationHelp(version));
1789
2526
  });
1790
2527
  },
1791
2528
  }),
@@ -1815,8 +2552,8 @@ const completionsCommand = defineCommand({
1815
2552
  const script = generateBashCompletions(main);
1816
2553
  if (args.install) {
1817
2554
  const dest = installBashCompletions(script);
1818
- console.error(`Completions installed to ${dest}`);
1819
- console.error(`Restart your shell or run: source ${dest}`);
2555
+ info(`Completions installed to ${dest}`);
2556
+ info(`Restart your shell or run: source ${dest}`);
1820
2557
  }
1821
2558
  else {
1822
2559
  process.stdout.write(script);
@@ -1912,6 +2649,13 @@ function resolveVaultPath(ref) {
1912
2649
  const source = findVaultSource(parsed.origin);
1913
2650
  const typeRoot = path.join(source.path, "vaults");
1914
2651
  const absPath = resolveAssetPathFromName("vault", typeRoot, parsed.name);
2652
+ // Defense-in-depth: ensure the resolved path stays inside the vaults directory.
2653
+ // validateName already rejects traversal patterns like "../../foo", but an
2654
+ // absolute-path override or symlink-based attack could still escape without
2655
+ // this second check.
2656
+ if (!isWithin(absPath, typeRoot)) {
2657
+ throw new UsageError(`Vault name "${parsed.name}" escapes the vault directory.`);
2658
+ }
1915
2659
  return { name: parsed.name, absPath, source, parsedRef: parsed };
1916
2660
  }
1917
2661
  /**
@@ -1940,6 +2684,10 @@ function listVaultsRecursive(listKeysFn) {
1940
2684
  const canonical = deriveCanonicalAssetName("vault", vaultsDir, full);
1941
2685
  if (!canonical)
1942
2686
  continue;
2687
+ // Skip sensitive vaults: presence of a sibling .sensitive marker file suppresses listing.
2688
+ const markerPath = full.replace(/\.env$/, ".sensitive");
2689
+ if (fs.existsSync(markerPath))
2690
+ continue;
1943
2691
  const { keys } = listKeysFn(full);
1944
2692
  result.push({ ref: makeVaultRef(canonical, source), path: full, keys });
1945
2693
  }
@@ -1962,6 +2710,9 @@ function splitVaultRunTarget(target) {
1962
2710
  if (!key) {
1963
2711
  throw new UsageError("Expected vault run target in the form <ref> or <ref/KEY>.");
1964
2712
  }
2713
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
2714
+ throw new UsageError(`"${key}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
2715
+ }
1965
2716
  const resolved = resolveVaultPath(refPart);
1966
2717
  if (!fs.existsSync(resolved.absPath)) {
1967
2718
  throw new NotFoundError(`Vault not found: ${makeVaultRef(resolved.name, resolved.source)}`);
@@ -1982,48 +2733,100 @@ const vaultCreateCommand = defineCommand({
1982
2733
  meta: { name: "create", description: "Create an empty vault file (no-op if it already exists)" },
1983
2734
  args: {
1984
2735
  name: { type: "positional", description: "Vault name (e.g. prod) — file becomes <name>.env", required: true },
2736
+ sensitive: {
2737
+ type: "boolean",
2738
+ description: "Exclude this vault from vault list output and the search index",
2739
+ default: false,
2740
+ },
1985
2741
  },
1986
2742
  run({ args }) {
1987
2743
  return runWithJsonErrors(async () => {
1988
2744
  const { createVault } = await import("./commands/vault.js");
1989
2745
  const { name, absPath, source } = resolveVaultPath(args.name);
1990
2746
  createVault(absPath);
1991
- output("vault-create", { ref: makeVaultRef(name, source), path: absPath });
2747
+ if (args.sensitive) {
2748
+ const markerPath = absPath.replace(/\.env$/, ".sensitive");
2749
+ if (!fs.existsSync(markerPath)) {
2750
+ fs.writeFileSync(markerPath, "", { mode: 0o600 });
2751
+ }
2752
+ }
2753
+ output("vault-create", { ref: makeVaultRef(name, source) });
1992
2754
  });
1993
2755
  },
1994
2756
  });
1995
2757
  const vaultSetCommand = defineCommand({
1996
2758
  meta: {
1997
2759
  name: "set",
1998
- description: 'Set a key in a vault. Value is written to disk and never echoed back. Accepts KEY=VALUE combined form or separate KEY VALUE args. Optionally attach a comment with --comment "description".',
2760
+ description: 'Set a key in a vault. Value is read from stdin by default (never via argv, avoiding /proc/cmdline exposure). Use --from-env <VAR> to read from an environment variable instead. Optionally attach a comment with --comment "description".',
1999
2761
  },
2000
2762
  args: {
2001
2763
  ref: { type: "positional", description: "Vault ref (e.g. vault:prod or just prod)", required: true },
2002
- key: { type: "positional", description: "Key name (e.g. DB_URL) or KEY=VALUE combined form", required: true },
2003
- value: {
2004
- type: "positional",
2005
- description: "Value to store (omit when using KEY=VALUE combined form)",
2006
- required: false,
2007
- },
2764
+ key: { type: "positional", description: "Key name (e.g. DB_URL)", required: true },
2008
2765
  comment: { type: "string", description: "Optional comment written above the key line", required: false },
2766
+ "from-env": {
2767
+ type: "string",
2768
+ description: "Read value from the named environment variable instead of stdin",
2769
+ },
2009
2770
  },
2010
2771
  run({ args }) {
2011
2772
  return runWithJsonErrors(async () => {
2773
+ // Trap the legacy 3-positional / KEY=VALUE forms removed in 0.8.0.
2774
+ //
2775
+ // Without this trap, citty silently accepts the extra positional and the
2776
+ // command falls through to read stdin. In cron/CI where stdin is empty,
2777
+ // the existing secret would be silently overwritten with an empty string
2778
+ // (silent data loss). Detect both removed forms and hard-error before
2779
+ // touching the vault file.
2780
+ //
2781
+ // Case 1: `akm vault set <ref> KEY=VALUE` — the second positional
2782
+ // contains `=`. Argv looks like:
2783
+ // [..., "vault", "set", "<ref>", "KEY=VALUE", ...]
2784
+ // citty binds `args.key = "KEY=VALUE"`.
2785
+ // Case 2: `akm vault set <ref> KEY VALUE` — a third positional follows
2786
+ // the key. citty mirrors every positional (including the
2787
+ // declared ones) into `args._`, so we detect this by length:
2788
+ // more than 2 positionals means an extra `<VALUE>` was passed.
2789
+ const allPositionals = Array.isArray(args._) ? args._.filter((v) => typeof v === "string") : [];
2790
+ const hasExtraPositional = allPositionals.length > 2;
2791
+ const keyHasEquals = typeof args.key === "string" && args.key.includes("=");
2792
+ if (hasExtraPositional || keyHasEquals) {
2793
+ throw new UsageError("'akm vault set' no longer accepts the value via argv (removed in 0.8.0 for security).\n" +
2794
+ " Pass the value via stdin or --from-env <VAR> instead:\n" +
2795
+ " printf '%s' \"$SECRET\" | akm vault set <ref> <KEY>\n" +
2796
+ ' AKM_VALUE="$SECRET" akm vault set <ref> <KEY> --from-env AKM_VALUE', "INVALID_FLAG_VALUE");
2797
+ }
2012
2798
  const { setKey } = await import("./commands/vault.js");
2013
2799
  const { name, absPath, source } = resolveVaultPath(args.ref);
2014
- let realKey;
2800
+ const fromEnv = getHyphenatedArg(args, "from-env");
2015
2801
  let realValue;
2016
- if ((args.value === undefined || args.value === "") && args.key.includes("=")) {
2017
- const eqIdx = args.key.indexOf("=");
2018
- realKey = args.key.slice(0, eqIdx);
2019
- realValue = args.key.slice(eqIdx + 1);
2802
+ if (fromEnv !== undefined) {
2803
+ const envVal = process.env[fromEnv];
2804
+ if (envVal === undefined) {
2805
+ throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
2806
+ }
2807
+ realValue = envVal;
2020
2808
  }
2021
2809
  else {
2022
- realKey = args.key;
2023
- realValue = args.value ?? "";
2810
+ // Print a prompt when stdin is attached to a terminal so an
2811
+ // interactive invocation doesn't silently hang with no indication
2812
+ // that input is being awaited.
2813
+ if (process.stdin.isTTY) {
2814
+ process.stderr.write(`Enter value for "${args.key}" (Ctrl-D when done):\n`);
2815
+ }
2816
+ const MAX_VAULT_VALUE_BYTES = 1024 * 1024; // 1 MB
2817
+ let totalBytes = 0;
2818
+ const chunks = [];
2819
+ for await (const chunk of Bun.stdin.stream()) {
2820
+ totalBytes += chunk.byteLength;
2821
+ if (totalBytes > MAX_VAULT_VALUE_BYTES) {
2822
+ throw new UsageError("Vault value exceeds 1 MB limit. Values must be provided via stdin.");
2823
+ }
2824
+ chunks.push(chunk);
2825
+ }
2826
+ realValue = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
2024
2827
  }
2025
- setKey(absPath, realKey, realValue, args.comment);
2026
- output("vault-set", { ref: makeVaultRef(name, source), key: realKey, path: absPath });
2828
+ setKey(absPath, args.key, realValue, args.comment);
2829
+ output("vault-set", { ref: makeVaultRef(name, source), key: args.key });
2027
2830
  });
2028
2831
  },
2029
2832
  });
@@ -2032,16 +2835,24 @@ const vaultUnsetCommand = defineCommand({
2032
2835
  args: {
2033
2836
  ref: { type: "positional", description: "Vault ref", required: true },
2034
2837
  key: { type: "positional", description: "Key name to remove", required: true },
2838
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2035
2839
  },
2036
2840
  run({ args }) {
2037
2841
  return runWithJsonErrors(async () => {
2038
- const { unsetKey } = await import("./commands/vault.js");
2842
+ // Validate path first so traversal errors surface before the confirmation prompt.
2039
2843
  const { name, absPath, source } = resolveVaultPath(args.ref);
2844
+ const { confirmDestructive } = await import("./cli/confirm.js");
2845
+ const confirmed = await confirmDestructive(`Remove key "${args.key}" from vault "${args.ref}"? This cannot be undone.`, { yes: args.yes === true });
2846
+ if (!confirmed) {
2847
+ process.stderr.write("Aborted.\n");
2848
+ return;
2849
+ }
2850
+ const { unsetKey } = await import("./commands/vault.js");
2040
2851
  if (!fs.existsSync(absPath)) {
2041
2852
  throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
2042
2853
  }
2043
2854
  const removed = unsetKey(absPath, args.key);
2044
- output("vault-unset", { ref: makeVaultRef(name, source), key: args.key, removed, path: absPath });
2855
+ output("vault-unset", { ref: makeVaultRef(name, source), key: args.key, removed });
2045
2856
  });
2046
2857
  },
2047
2858
  });
@@ -2097,6 +2908,15 @@ const vaultRunCommand = defineCommand({
2097
2908
  mergedEnv[envKey] = envValue;
2098
2909
  }
2099
2910
  }
2911
+ // Emit vault access event (keys only, no values) for audit trail.
2912
+ // Best-effort: never block vault run on event write failure.
2913
+ appendEvent({
2914
+ eventType: "vault_access",
2915
+ ref: makeVaultRef(name, source),
2916
+ metadata: {
2917
+ keys: key ? [key] : Object.keys(envValues),
2918
+ },
2919
+ });
2100
2920
  const result = spawnSync(command[0], command.slice(1), {
2101
2921
  stdio: "inherit",
2102
2922
  env: mergedEnv,
@@ -2122,7 +2942,7 @@ const vaultCommand = defineCommand({
2122
2942
  },
2123
2943
  run({ args }) {
2124
2944
  return runWithJsonErrors(async () => {
2125
- if (hasVaultSubcommand(args))
2945
+ if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
2126
2946
  return;
2127
2947
  // Default action: list all vaults
2128
2948
  const { listKeys } = await import("./commands/vault.js");
@@ -2158,11 +2978,6 @@ const wikiRegisterCommand = defineCommand({
2158
2978
  description: "Mark a git-backed source as writable so changes can be pushed back",
2159
2979
  default: false,
2160
2980
  },
2161
- trust: {
2162
- type: "boolean",
2163
- description: "Bypass install-audit blocking for this registration only",
2164
- default: false,
2165
- },
2166
2981
  "max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
2167
2982
  "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
2168
2983
  },
@@ -2173,7 +2988,6 @@ const wikiRegisterCommand = defineCommand({
2173
2988
  ref: args.ref.trim(),
2174
2989
  name: args.name,
2175
2990
  options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
2176
- trustThisInstall: args.trust,
2177
2991
  writable: args.writable,
2178
2992
  });
2179
2993
  output("wiki-register", result);
@@ -2286,12 +3100,39 @@ const wikiStashCommand = defineCommand({
2286
3100
  name: { type: "positional", description: "Wiki name", required: true },
2287
3101
  source: { type: "positional", description: "Source file path, URL, or '-' to read from stdin", required: true },
2288
3102
  as: { type: "string", description: "Preferred slug base (defaults to source filename or first-line slug)" },
3103
+ target: {
3104
+ type: "string",
3105
+ description: "Name of a writable stash source to write into instead of the default stash. Must match a configured source name (run `akm list` to see sources).",
3106
+ },
2289
3107
  },
2290
3108
  run({ args }) {
2291
3109
  return runWithJsonErrors(async () => {
2292
3110
  const { stashRaw } = await import("./wiki/wiki.js");
2293
- const { content, preferredName } = await readKnowledgeInput(args.source);
2294
- const stashDir = resolveStashDir();
3111
+ const { content, preferredName } = await (async () => {
3112
+ if (!isHttpUrl(args.source))
3113
+ return readKnowledgeInput(args.source);
3114
+ const { fetchWebsiteMarkdownSnapshot } = await import("./sources/website-ingest");
3115
+ const snapshot = await fetchWebsiteMarkdownSnapshot(args.source);
3116
+ return { content: snapshot.content, preferredName: args.as ?? snapshot.preferredName };
3117
+ })();
3118
+ let stashDir;
3119
+ if (args.target) {
3120
+ // Resolve the named source to its filesystem path.
3121
+ const cfg = loadConfig();
3122
+ const sources = resolveConfiguredSources(cfg);
3123
+ const match = sources.find((s) => s.name === args.target);
3124
+ if (!match) {
3125
+ throw new UsageError(`--target must reference a configured source name. No source named "${args.target}" found. Run \`akm list\` to see available sources.`, "INVALID_FLAG_VALUE");
3126
+ }
3127
+ const spec = match.source;
3128
+ if (spec.type !== "filesystem" && spec.type !== "local") {
3129
+ throw new ConfigError(`Source "${args.target}" is not a filesystem source and cannot be used as a wiki stash target.`, "INVALID_CONFIG_FILE", `Use a source with type "filesystem" or "local", or omit --target to use the default stash.`);
3130
+ }
3131
+ stashDir = spec.path;
3132
+ }
3133
+ else {
3134
+ stashDir = resolveStashDir();
3135
+ }
2295
3136
  const result = stashRaw({
2296
3137
  stashDir,
2297
3138
  wikiName: args.name,
@@ -2327,17 +3168,52 @@ const wikiLintCommand = defineCommand({
2327
3168
  const wikiIngestCommand = defineCommand({
2328
3169
  meta: {
2329
3170
  name: "ingest",
2330
- description: "Print the ingest workflow for this wiki. Does not perform the ingest; instructs the agent to.",
3171
+ description: "Dispatch an agent to execute the ingest workflow for this wiki. Uses --profile or config.defaults.agent.",
2331
3172
  },
2332
3173
  args: {
2333
3174
  name: { type: "positional", description: "Wiki name", required: true },
3175
+ profile: {
3176
+ type: "string",
3177
+ description: "Agent profile to use (default: config.defaults.agent).",
3178
+ },
3179
+ model: {
3180
+ type: "string",
3181
+ description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs.",
3182
+ },
3183
+ "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds." },
2334
3184
  },
2335
3185
  run({ args }) {
2336
3186
  return runWithJsonErrors(async () => {
2337
3187
  const { buildIngestWorkflow } = await import("./wiki/wiki.js");
2338
3188
  const stashDir = resolveStashDir();
2339
- const result = buildIngestWorkflow(stashDir, args.name);
2340
- output("wiki-ingest", result);
3189
+ const built = buildIngestWorkflow(stashDir, args.name);
3190
+ const config = loadConfig();
3191
+ const profileName = getStringArg(args, "profile") ?? config.defaults?.agent;
3192
+ if (!profileName) {
3193
+ throw new UsageError("akm wiki ingest requires an agent profile. Pass --profile <name> or set defaults.agent in config.", "MISSING_REQUIRED_ARGUMENT", "Available profiles are listed under profiles.agent in your config. Run `akm config get profiles.agent` to inspect.");
3194
+ }
3195
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
3196
+ const model = getStringArg(args, "model");
3197
+ const { getDefaultLlmConfig } = await import("./core/config.js");
3198
+ const dispatchResult = await akmAgentDispatch({
3199
+ profileName,
3200
+ agentConfig: config,
3201
+ llmConfig: getDefaultLlmConfig(config),
3202
+ prompt: built.workflow,
3203
+ dispatch: {
3204
+ prompt: built.workflow,
3205
+ ...(model !== undefined ? { model } : {}),
3206
+ },
3207
+ ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
3208
+ });
3209
+ output("wiki-ingest", {
3210
+ wiki: built.wiki,
3211
+ path: built.path,
3212
+ schemaPath: built.schemaPath,
3213
+ dispatched: true,
3214
+ profile: profileName,
3215
+ agentResult: dispatchResult,
3216
+ });
2341
3217
  });
2342
3218
  },
2343
3219
  });
@@ -2360,7 +3236,7 @@ const wikiCommand = defineCommand({
2360
3236
  },
2361
3237
  run({ args }) {
2362
3238
  return runWithJsonErrors(async () => {
2363
- if (hasWikiSubcommand(args))
3239
+ if (hasSubcommand(args, WIKI_SUBCOMMAND_SET))
2364
3240
  return;
2365
3241
  // Default action: list wikis
2366
3242
  const { listWikis } = await import("./wiki/wiki.js");
@@ -2369,15 +3245,15 @@ const wikiCommand = defineCommand({
2369
3245
  },
2370
3246
  });
2371
3247
  // ── `akm events` ────────────────────────────────────────────────────────────
2372
- // Append-only events stream surface (#204). `list` reads `events.jsonl`
2373
- // with optional --since/--type/--ref filters; `tail` follows the file via
3248
+ // Append-only events stream surface (#204). `list` reads state.db events
3249
+ // with optional --since/--type/--ref filters; `tail` follows the table via
2374
3250
  // a polling loop and prints each event as a single JSONL line.
2375
3251
  const eventsListCommand = defineCommand({
2376
- meta: { name: "list", description: "List events from the append-only events.jsonl stream" },
3252
+ meta: { name: "list", description: "List events from the append-only state.db events stream" },
2377
3253
  args: {
2378
3254
  since: {
2379
3255
  type: "string",
2380
- description: "ISO timestamp / epoch ms, OR `@offset:<bytes>` for a durable byte-cursor (resume across processes)",
3256
+ description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
2381
3257
  },
2382
3258
  type: { type: "string", description: "Filter by event type (add, remove, remember, feedback, ...)" },
2383
3259
  ref: { type: "string", description: "Filter by asset ref (type:name)" },
@@ -2406,11 +3282,11 @@ const eventsListCommand = defineCommand({
2406
3282
  },
2407
3283
  });
2408
3284
  const eventsTailCommand = defineCommand({
2409
- meta: { name: "tail", description: "Follow the append-only events.jsonl stream (polling)" },
3285
+ meta: { name: "tail", description: "Follow the append-only state.db events stream (polling)" },
2410
3286
  args: {
2411
3287
  since: {
2412
3288
  type: "string",
2413
- description: "ISO timestamp / epoch ms, OR `@offset:<bytes>` for a durable byte-cursor (resume across processes)",
3289
+ description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
2414
3290
  },
2415
3291
  type: { type: "string", description: "Filter by event type" },
2416
3292
  ref: { type: "string", description: "Filter by asset ref (type:name)" },
@@ -2428,9 +3304,9 @@ const eventsTailCommand = defineCommand({
2428
3304
  },
2429
3305
  async run({ args }) {
2430
3306
  await runWithJsonErrors(async () => {
2431
- const intervalMs = parsePositiveInt(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
2432
- const maxDurationMs = parsePositiveInt(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
2433
- const maxEvents = parsePositiveInt(getHyphenatedArg(args, "max-events"), "--max-events");
3307
+ const intervalMs = parsePositiveIntFlag(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
3308
+ const maxDurationMs = parsePositiveIntFlag(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
3309
+ const maxEvents = parsePositiveIntFlag(getHyphenatedArg(args, "max-events"), "--max-events");
2434
3310
  const mode = getOutputMode();
2435
3311
  // In streaming text mode we want each event to print as soon as it
2436
3312
  // arrives. The polling loop emits via `onEvent`; the final result is
@@ -2484,39 +3360,102 @@ const eventsTailCommand = defineCommand({
2484
3360
  });
2485
3361
  },
2486
3362
  });
2487
- function parsePositiveInt(raw, flag) {
2488
- if (raw === undefined)
2489
- return undefined;
2490
- const trimmed = raw.trim();
2491
- if (!trimmed)
2492
- return undefined;
2493
- const value = Number.parseInt(trimmed, 10);
2494
- if (Number.isNaN(value) || value <= 0) {
2495
- throw new UsageError(`Invalid ${flag} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
2496
- }
2497
- return value;
2498
- }
2499
3363
  const eventsCommand = defineCommand({
2500
3364
  meta: {
2501
3365
  name: "events",
2502
- description: "Read or follow the append-only events.jsonl stream (mutations, feedback, indexing)",
3366
+ description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
2503
3367
  },
2504
3368
  subCommands: {
2505
3369
  list: eventsListCommand,
2506
3370
  tail: eventsTailCommand,
2507
3371
  },
2508
3372
  });
3373
+ // ── lessons subcommands (Phase 7A / Advantage D4c) ──────────────────────────
3374
+ const lessonsCoverageCommand = defineCommand({
3375
+ meta: {
3376
+ name: "coverage",
3377
+ description: "Report tags that exist on indexed assets but are NOT yet covered by any lesson.\n\n" +
3378
+ "Useful for spotting topics where the stash has skills/commands/scripts but no\n" +
3379
+ "crystallized lesson — a signal that the team has tacit knowledge worth distilling.\n\n" +
3380
+ "Default output is JSON: { uncoveredTags: string[], lessonTagCount: number, totalTagCount: number }.\n" +
3381
+ "Pass --format text for a plain-text bulleted list.",
3382
+ },
3383
+ args: {},
3384
+ run() {
3385
+ return runWithJsonErrors(() => {
3386
+ const db = openExistingDatabase();
3387
+ try {
3388
+ const allTagSet = collectTagSetFromEntries(db, undefined);
3389
+ const lessonTagSet = collectTagSetFromEntries(db, "lesson");
3390
+ const uncovered = [];
3391
+ for (const tag of allTagSet) {
3392
+ if (!lessonTagSet.has(tag))
3393
+ uncovered.push(tag);
3394
+ }
3395
+ uncovered.sort((a, b) => a.localeCompare(b));
3396
+ output("lessons-coverage", {
3397
+ ok: true,
3398
+ uncoveredTags: uncovered,
3399
+ lessonTagCount: lessonTagSet.size,
3400
+ totalTagCount: allTagSet.size,
3401
+ });
3402
+ }
3403
+ finally {
3404
+ closeDatabase(db);
3405
+ }
3406
+ });
3407
+ },
3408
+ });
3409
+ /**
3410
+ * Walk indexed entries and collect a deduplicated set of tags. When
3411
+ * `entryType` is provided, only entries of that type contribute tags.
3412
+ *
3413
+ * Pure read; never mutates the DB. Used by `akm lessons coverage` (Phase 7A)
3414
+ * to compute the diff between all-asset tags and lesson tags.
3415
+ */
3416
+ function collectTagSetFromEntries(db, entryType) {
3417
+ const tags = new Set();
3418
+ const stmt = entryType
3419
+ ? db.prepare("SELECT entry_json FROM entries WHERE entry_type = ?")
3420
+ : db.prepare("SELECT entry_json FROM entries");
3421
+ const rows = (entryType ? stmt.all(entryType) : stmt.all());
3422
+ for (const row of rows) {
3423
+ let parsed;
3424
+ try {
3425
+ parsed = JSON.parse(row.entry_json);
3426
+ }
3427
+ catch {
3428
+ continue;
3429
+ }
3430
+ if (!Array.isArray(parsed.tags))
3431
+ continue;
3432
+ for (const tag of parsed.tags) {
3433
+ if (typeof tag === "string" && tag.trim().length > 0) {
3434
+ tags.add(tag.trim().toLowerCase());
3435
+ }
3436
+ }
3437
+ }
3438
+ return tags;
3439
+ }
3440
+ const lessonsCommand = defineCommand({
3441
+ meta: {
3442
+ name: "lessons",
3443
+ description: "Lesson-asset tooling: tag-coverage gaps, strength queries.",
3444
+ },
3445
+ subCommands: {
3446
+ coverage: lessonsCoverageCommand,
3447
+ },
3448
+ });
2509
3449
  // ── proposal substrate (#225) ────────────────────────────────────────────────
2510
- const proposalListCommand = defineCommand({
2511
- meta: { name: "list", description: "List pending proposals (use --include-archive to see decided ones)" },
3450
+ const proposalsCommand = defineCommand({
3451
+ meta: { name: "proposals", description: "List proposal queue entries" },
2512
3452
  args: {
2513
- status: { type: "string", description: "Filter by status (pending|accepted|rejected)" },
2514
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
2515
- "include-archive": {
2516
- type: "boolean",
2517
- description: "Include accepted/rejected proposals from the archive",
2518
- default: false,
3453
+ status: {
3454
+ type: "string",
3455
+ description: "Filter by status (pending|accepted|rejected|reverted)",
2519
3456
  },
3457
+ ref: { type: "string", description: "Filter by asset ref (type:name)" },
3458
+ type: { type: "string", description: "Filter by asset type" },
2520
3459
  },
2521
3460
  run({ args }) {
2522
3461
  return runWithJsonErrors(() => {
@@ -2524,54 +3463,203 @@ const proposalListCommand = defineCommand({
2524
3463
  const result = akmProposalList({
2525
3464
  status,
2526
3465
  ref: args.ref,
2527
- includeArchive: getHyphenatedBoolean(args, "include-archive"),
3466
+ includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
2528
3467
  });
2529
3468
  output("proposal-list", result);
2530
3469
  });
2531
3470
  },
2532
3471
  });
2533
- const proposalShowCommand = defineCommand({
2534
- meta: { name: "show", description: "Show a proposal's metadata, payload, and validation report" },
2535
- args: {
2536
- id: { type: "positional", description: "Proposal id (uuid)", required: true },
2537
- },
2538
- run({ args }) {
2539
- return runWithJsonErrors(() => {
2540
- const result = akmProposalShow({ id: args.id });
2541
- output("proposal-show", result);
2542
- });
2543
- },
2544
- });
2545
- const proposalAcceptCommand = defineCommand({
2546
- meta: { name: "accept", description: "Validate and promote a proposal to a real asset" },
3472
+ const acceptCommand = defineCommand({
3473
+ meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
2547
3474
  args: {
2548
- id: { type: "positional", description: "Proposal id (uuid)", required: true },
3475
+ id: {
3476
+ type: "positional",
3477
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --source is provided.",
3478
+ required: false,
3479
+ },
2549
3480
  target: { type: "string", description: "Override the write target by source name" },
3481
+ // F-6 / #393: Batch accept by source, diff size, or age.
3482
+ source: {
3483
+ type: "string",
3484
+ description: "F-6: Bulk-accept all pending proposals from this source (e.g. reflect, distill). Requires no positional id.",
3485
+ },
3486
+ "max-diff-lines": {
3487
+ type: "string",
3488
+ description: "F-6: When bulk-accepting, only accept proposals whose content is <= this many lines. Skips larger proposals.",
3489
+ },
3490
+ "older-than": {
3491
+ type: "string",
3492
+ description: "F-6: When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
3493
+ },
3494
+ "dry-run": {
3495
+ type: "boolean",
3496
+ description: "F-6: List proposals that would be bulk-accepted without accepting them.",
3497
+ default: false,
3498
+ },
2550
3499
  },
2551
3500
  async run({ args }) {
2552
3501
  await runWithJsonErrors(async () => {
3502
+ // F-6 / #393: Bulk-accept when --source is provided without a positional id.
3503
+ if (args.source && !args.id) {
3504
+ const { listProposals } = await import("./core/proposals");
3505
+ const stashDir = resolveStashDir();
3506
+ const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3507
+ if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3508
+ throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3509
+ }
3510
+ const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3511
+ if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3512
+ throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3513
+ }
3514
+ const maxDiffLines = rawMaxDiff;
3515
+ const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3516
+ const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3517
+ if (p.source !== args.source)
3518
+ return false;
3519
+ if (maxDiffLines !== undefined) {
3520
+ const lines = (p.payload.content ?? "").split("\n").length;
3521
+ if (lines > maxDiffLines)
3522
+ return false;
3523
+ }
3524
+ if (olderThanMs !== undefined) {
3525
+ const age = Date.now() - new Date(p.createdAt).getTime();
3526
+ if (age < olderThanMs)
3527
+ return false;
3528
+ }
3529
+ return true;
3530
+ });
3531
+ const results = [];
3532
+ for (const proposal of pending) {
3533
+ if (args["dry-run"]) {
3534
+ results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3535
+ }
3536
+ else {
3537
+ const result = await akmProposalAccept({ id: proposal.id, target: args.target });
3538
+ results.push(result);
3539
+ }
3540
+ }
3541
+ output("proposal-accept-batch", { accepted: results.length, results, dryRun: args["dry-run"] });
3542
+ return;
3543
+ }
3544
+ if (!args.id) {
3545
+ throw new UsageError("Usage: akm accept <id> OR akm accept --source <source>", "MISSING_REQUIRED_ARGUMENT");
3546
+ }
2553
3547
  const result = await akmProposalAccept({ id: args.id, target: args.target });
2554
3548
  output("proposal-accept", result);
2555
3549
  });
2556
3550
  },
2557
3551
  });
2558
- const proposalRejectCommand = defineCommand({
2559
- meta: { name: "reject", description: "Archive a pending proposal with an optional reason" },
3552
+ const rejectCommand = defineCommand({
3553
+ meta: { name: "reject", description: "Reject a proposal and record the reason" },
2560
3554
  args: {
2561
- id: { type: "positional", description: "Proposal id (uuid)", required: true },
2562
- reason: { type: "string", description: "Reason for rejection (recorded in the archived proposal)" },
3555
+ id: {
3556
+ type: "positional",
3557
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --source is provided.",
3558
+ required: false,
3559
+ },
3560
+ reason: { type: "string", description: "Reason for rejection (required)" },
3561
+ // F-6 / #393: Batch reject by source, diff size, or age.
3562
+ source: {
3563
+ type: "string",
3564
+ description: "F-6: Bulk-reject all pending proposals from this source (e.g. reflect, distill). Requires no positional id.",
3565
+ },
3566
+ "max-diff-lines": {
3567
+ type: "string",
3568
+ description: "F-6: When bulk-rejecting, only reject proposals whose content is <= this many lines. Skips larger proposals.",
3569
+ },
3570
+ "older-than": {
3571
+ type: "string",
3572
+ description: "F-6: When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
3573
+ },
3574
+ "dry-run": {
3575
+ type: "boolean",
3576
+ description: "F-6: List proposals that would be bulk-rejected without rejecting them.",
3577
+ default: false,
3578
+ },
3579
+ yes: {
3580
+ type: "boolean",
3581
+ alias: "y",
3582
+ description: "Skip confirmation prompt (required in non-interactive mode)",
3583
+ default: false,
3584
+ },
2563
3585
  },
2564
3586
  run({ args }) {
2565
- return runWithJsonErrors(() => {
2566
- const result = akmProposalReject({ id: args.id, reason: args.reason });
2567
- output("proposal-reject", result);
3587
+ return runWithJsonErrors(async () => {
3588
+ if (!args.reason || !String(args.reason).trim()) {
3589
+ throw new UsageError("Usage: akm reject <id> --reason '<reason>' OR akm reject --source <source> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3590
+ }
3591
+ // F-6 / #393: Bulk-reject when --source is provided without a positional id.
3592
+ if (args.source && !args.id) {
3593
+ const { confirmDestructive } = await import("./cli/confirm.js");
3594
+ const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from source "${args.source}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
3595
+ if (!confirmed) {
3596
+ process.stderr.write("Aborted.\n");
3597
+ return;
3598
+ }
3599
+ const { listProposals } = await import("./core/proposals");
3600
+ const stashDir = resolveStashDir();
3601
+ const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3602
+ if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3603
+ throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3604
+ }
3605
+ const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3606
+ if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3607
+ throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3608
+ }
3609
+ const maxDiffLines = rawMaxDiff;
3610
+ const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3611
+ const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3612
+ if (p.source !== args.source)
3613
+ return false;
3614
+ if (maxDiffLines !== undefined) {
3615
+ const lines = (p.payload.content ?? "").split("\n").length;
3616
+ if (lines > maxDiffLines)
3617
+ return false;
3618
+ }
3619
+ if (olderThanMs !== undefined) {
3620
+ const age = Date.now() - new Date(p.createdAt).getTime();
3621
+ if (age < olderThanMs)
3622
+ return false;
3623
+ }
3624
+ return true;
3625
+ });
3626
+ const results = [];
3627
+ for (const proposal of pending) {
3628
+ if (args["dry-run"]) {
3629
+ results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3630
+ }
3631
+ else {
3632
+ const result = akmProposalReject({ id: proposal.id, reason: String(args.reason) });
3633
+ results.push(result);
3634
+ }
3635
+ }
3636
+ output("proposal-reject-batch", { rejected: results.length, results, dryRun: args["dry-run"] });
3637
+ return;
3638
+ }
3639
+ if (!args.id) {
3640
+ throw new UsageError("Usage: akm reject <id> --reason '<reason>' OR akm reject --source <source> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3641
+ }
3642
+ const { confirmDestructive } = await import("./cli/confirm.js");
3643
+ const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? This cannot be undone.`, {
3644
+ yes: args.yes === true,
3645
+ });
3646
+ if (!confirmed) {
3647
+ process.stderr.write("Aborted.\n");
3648
+ return;
3649
+ }
3650
+ const result = akmProposalReject({ id: args.id, reason: String(args.reason) });
3651
+ output("proposal-reject", result);
2568
3652
  });
2569
3653
  },
2570
3654
  });
2571
- const proposalDiffCommand = defineCommand({
2572
- meta: { name: "diff", description: "Show the diff between an existing asset and a pending proposal" },
3655
+ const diffCommand = defineCommand({
3656
+ meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
2573
3657
  args: {
2574
- id: { type: "positional", description: "Proposal id (uuid)", required: true },
3658
+ id: {
3659
+ type: "positional",
3660
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
3661
+ required: true,
3662
+ },
2575
3663
  target: { type: "string", description: "Override the write target by source name" },
2576
3664
  },
2577
3665
  run({ args }) {
@@ -2581,152 +3669,327 @@ const proposalDiffCommand = defineCommand({
2581
3669
  });
2582
3670
  },
2583
3671
  });
2584
- const proposalCommand = defineCommand({
2585
- meta: {
2586
- name: "proposal",
2587
- description: "Review and promote queued asset proposals (durable storage under .akm/proposals/)",
2588
- },
2589
- subCommands: {
2590
- list: proposalListCommand,
2591
- show: proposalShowCommand,
2592
- accept: proposalAcceptCommand,
2593
- reject: proposalRejectCommand,
2594
- diff: proposalDiffCommand,
2595
- },
2596
- });
2597
- // ── distill (#228) ──────────────────────────────────────────────────────────
2598
- const distillCommand = defineCommand({
3672
+ // Phase 6C (Advantage D6c): revert an accepted proposal.
3673
+ //
3674
+ // Exit codes (mapped by `runWithJsonErrors` from the typed errors thrown by
3675
+ // `akmProposalRevert` / `revertProposal`):
3676
+ // 0 — success; prior content restored.
3677
+ // 1 — generic error (also used by `UsageError("INVALID_FLAG_VALUE")` and
3678
+ // `UsageError("MISSING_REQUIRED_ARGUMENT")` when the proposal is not
3679
+ // accepted, or no backup is available).
3680
+ // 1 — `NotFoundError("FILE_NOT_FOUND")` when the proposal id does not resolve.
3681
+ const revertCommand = defineCommand({
2599
3682
  meta: {
2600
- name: "distill",
2601
- description: "Distil feedback for an asset into a queued lesson proposal (gated on llm.features.feedback_distillation)",
3683
+ name: "revert",
3684
+ description: "Revert an accepted proposal: restore the prior asset content from the backup captured at promotion time. " +
3685
+ "Errors if the proposal is not accepted or has no backup (new-asset proposals leave no backup). " +
3686
+ "Accepts the full proposal UUID or the asset ref. UUID prefixes are not supported for archived proposals — use the full UUID.",
2602
3687
  },
2603
3688
  args: {
2604
- ref: { type: "positional", description: "Asset ref (type:name) to distil from", required: true },
2605
- "source-run": {
2606
- type: "string",
2607
- description: "Optional run id propagated onto the queued proposal for traceability",
2608
- },
2609
- "exclude-feedback-from": {
2610
- type: "string",
2611
- description: "Comma-separated asset refs whose feedback events MUST be filtered out before the LLM input is built. Falls back to AKM_DISTILL_EXCLUDE_FEEDBACK_FROM when omitted.",
2612
- },
2613
- "exclude-tags": {
2614
- type: "string",
2615
- description: "Exclude feedback events matching these tags (repeatable, e.g. --exclude-tags slice:eval)",
2616
- },
2617
- "include-tags": {
2618
- type: "string",
2619
- description: "Only include feedback events with ALL these tags (repeatable)",
3689
+ id: {
3690
+ type: "positional",
3691
+ description: "Proposal id (full uuid) or asset ref (e.g. skill:akm-dream). UUID prefixes are not supported for archived proposals — use the full UUID.",
3692
+ required: true,
2620
3693
  },
3694
+ target: { type: "string", description: "Override the write target by source name" },
2621
3695
  },
2622
3696
  async run({ args }) {
2623
3697
  await runWithJsonErrors(async () => {
2624
- const excludeFlag = getHyphenatedArg(args, "exclude-feedback-from");
2625
- const excludeEnv = process.env.AKM_DISTILL_EXCLUDE_FEEDBACK_FROM;
2626
- // CLI flag takes precedence over the env var when both are present.
2627
- const excludeRaw = excludeFlag ?? excludeEnv;
2628
- const excludeFeedbackFromRefs = parseExcludeFeedbackFromRefs(excludeRaw);
2629
- const excludeTagsRaw = parseAllFlagValues("--exclude-tags");
2630
- const excludeTagsEnv = process.env.AKM_DISTILL_EXCLUDE_TAGS;
2631
- const excludeTags = [
2632
- ...new Set([
2633
- ...excludeTagsRaw,
2634
- ...(excludeTagsEnv
2635
- ? excludeTagsEnv
2636
- .split(",")
2637
- .map((s) => s.trim())
2638
- .filter(Boolean)
2639
- : []),
2640
- ]),
2641
- ];
2642
- const includeTagsRaw = parseAllFlagValues("--include-tags");
2643
- const includeTagsEnv = process.env.AKM_DISTILL_INCLUDE_TAGS;
2644
- const includeTags = [
2645
- ...new Set([
2646
- ...includeTagsRaw,
2647
- ...(includeTagsEnv
2648
- ? includeTagsEnv
2649
- .split(",")
2650
- .map((s) => s.trim())
2651
- .filter(Boolean)
2652
- : []),
2653
- ]),
2654
- ];
2655
- const result = await akmDistill({
2656
- ref: args.ref,
2657
- sourceRun: getHyphenatedArg(args, "source-run"),
2658
- ...(excludeFeedbackFromRefs.length > 0 ? { excludeFeedbackFromRefs } : {}),
2659
- ...(excludeTags.length > 0 ? { excludeTags } : {}),
2660
- ...(includeTags.length > 0 ? { includeTags } : {}),
3698
+ const result = await akmProposalRevert({
3699
+ id: args.id,
3700
+ target: args.target,
2661
3701
  });
2662
- output("distill", result);
3702
+ output("proposal-revert", result);
2663
3703
  });
2664
3704
  },
2665
3705
  });
2666
- /**
2667
- * Parse a comma-separated list of asset refs (#267 — `--exclude-feedback-from`
2668
- * and `AKM_DISTILL_EXCLUDE_FEEDBACK_FROM`). Each entry is validated against
2669
- * the canonical `[origin//]type:name` grammar via `parseAssetRef`; an
2670
- * invalid entry surfaces as a UsageError → exit 2.
2671
- */
2672
- function parseExcludeFeedbackFromRefs(raw) {
2673
- if (raw === undefined || raw.trim() === "")
2674
- return [];
2675
- const refs = raw
2676
- .split(",")
2677
- .map((part) => part.trim())
2678
- .filter((part) => part.length > 0);
2679
- for (const ref of refs) {
2680
- try {
2681
- parseAssetRef(ref);
2682
- }
2683
- catch (err) {
2684
- const message = err instanceof Error ? err.message : String(err);
2685
- throw new UsageError(`Invalid --exclude-feedback-from ref "${ref}": ${message}`, "INVALID_FLAG_VALUE", "Each ref must match `[origin//]type:name`, e.g. skill:deploy or team//memory:auth-tips.");
2686
- }
2687
- }
2688
- return refs;
2689
- }
3706
+ // ── distill (#228) ──────────────────────────────────────────────────────────
2690
3707
  function parseProposalStatus(raw) {
2691
3708
  if (raw === undefined)
2692
3709
  return undefined;
2693
3710
  const trimmed = raw.trim();
2694
3711
  if (!trimmed)
2695
3712
  return undefined;
2696
- if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected")
3713
+ if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected" || trimmed === "reverted") {
2697
3714
  return trimmed;
2698
- throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected.`, "INVALID_FLAG_VALUE");
3715
+ }
3716
+ throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected, reverted.`, "INVALID_FLAG_VALUE");
2699
3717
  }
2700
- // ── reflect / propose (agent proposal-producers, #226) ──────────────────────
2701
- const reflectCommand = defineCommand({
3718
+ const agentCommand = defineCommand({
2702
3719
  meta: {
2703
- name: "reflect",
2704
- description: "Ask the configured agent CLI to review an asset (or recent feedback) and queue a revised proposal",
3720
+ name: "agent",
3721
+ description: "Dispatch an agent CLI (opencode, claude, …) with an optional agent asset that provides the system prompt, model, and tool policy. Use <agent-ref> to embody a stash agent, --model to override the model, and --prompt/--command/--workflow to provide the task.",
2705
3722
  },
2706
3723
  args: {
2707
- ref: {
3724
+ profile: {
2708
3725
  type: "positional",
2709
- description: "Asset ref (type:name) to reflect on. Optional — omit to reflect across recent feedback.",
3726
+ description: "Agent profile / platform to use (opencode, claude, …)",
2710
3727
  required: false,
2711
3728
  },
2712
- task: { type: "string", description: "Optional task hint passed into the reflection prompt" },
2713
- profile: { type: "string", description: "Override the agent profile (defaults to agent.default)" },
3729
+ "agent-ref": {
3730
+ type: "positional",
3731
+ description: "Optional agent asset ref (e.g. agent:code-reviewer). Loads system prompt, model, and tool policy from the stash asset.",
3732
+ required: false,
3733
+ },
3734
+ prompt: { type: "string", description: "Task prompt to pass to the agent" },
3735
+ command: { type: "string", description: "Load prompt from a command: asset" },
3736
+ workflow: { type: "string", description: "Load prompt from a workflow: asset" },
3737
+ model: {
3738
+ type: "string",
3739
+ description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
3740
+ },
2714
3741
  "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
2715
3742
  },
2716
3743
  async run({ args }) {
2717
3744
  await runWithJsonErrors(async () => {
2718
- const timeoutRaw = args["timeout-ms"];
2719
- const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
2720
- const result = await akmReflect({
2721
- ref: typeof args.ref === "string" && args.ref.trim() ? args.ref : undefined,
2722
- task: typeof args.task === "string" && args.task.trim() ? args.task : undefined,
2723
- profile: typeof args.profile === "string" && args.profile.trim() ? args.profile : undefined,
3745
+ if (!args.profile) {
3746
+ throw new UsageError("Usage: akm agent <profile> [<agent-ref>] [--prompt <text>] [--model <model>]", "MISSING_REQUIRED_ARGUMENT", "Provide the agent profile name. Available profiles are listed in profiles.agent.");
3747
+ }
3748
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
3749
+ const config = loadConfig();
3750
+ const { getDefaultLlmConfig } = await import("./core/config.js");
3751
+ // After 0.8.0 the agent block IS the loaded AkmConfig.
3752
+ const agentConfig = config;
3753
+ // Resolve agent asset ref → extract system prompt, model, and tool policy.
3754
+ const agentRef = getStringArg(args, "agent-ref");
3755
+ let systemPrompt;
3756
+ let assetModel;
3757
+ let assetTools;
3758
+ if (agentRef) {
3759
+ const { akmShowUnified } = await import("./commands/show.js");
3760
+ const asset = await akmShowUnified({ ref: agentRef, detail: "full" });
3761
+ systemPrompt = typeof asset.content === "string" ? asset.content : undefined;
3762
+ assetModel = typeof asset.modelHint === "string" ? asset.modelHint : undefined;
3763
+ assetTools = asset.toolPolicy;
3764
+ }
3765
+ // --model flag wins over the asset's modelHint.
3766
+ const model = getStringArg(args, "model") ?? assetModel;
3767
+ const promptText = getStringArg(args, "prompt");
3768
+ const commandRef = getStringArg(args, "command");
3769
+ const workflowRef = getStringArg(args, "workflow");
3770
+ // Only build a dispatch request when there is something to dispatch — a
3771
+ // prompt, an agent asset, or a model override. When none of these are
3772
+ // present the agent is launched interactively (no injected prompt, no
3773
+ // platform-specific flags beyond the profile's base args).
3774
+ const hasDispatchContent = !!(promptText ?? commandRef ?? workflowRef ?? systemPrompt ?? model ?? assetTools);
3775
+ const result = await akmAgentDispatch({
3776
+ profileName: String(args.profile),
3777
+ prompt: promptText,
3778
+ commandRef,
3779
+ workflowRef,
3780
+ agentConfig,
3781
+ llmConfig: getDefaultLlmConfig(config),
3782
+ ...(hasDispatchContent
3783
+ ? {
3784
+ dispatch: {
3785
+ prompt: promptText ?? "",
3786
+ systemPrompt,
3787
+ model,
3788
+ tools: assetTools,
3789
+ },
3790
+ }
3791
+ : {}),
2724
3792
  ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
2725
3793
  });
2726
- output("reflect", result);
2727
- if (result.ok === false) {
3794
+ output("agent-result", result);
3795
+ if (!result.ok) {
3796
+ process.exit(EXIT_GENERAL);
3797
+ }
3798
+ });
3799
+ },
3800
+ });
3801
+ const lintCommand = defineCommand({
3802
+ meta: {
3803
+ name: "lint",
3804
+ description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues.",
3805
+ },
3806
+ args: {
3807
+ fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
3808
+ dir: { type: "string", description: "Override stash root directory (default: from config)" },
3809
+ },
3810
+ async run({ args }) {
3811
+ await runWithJsonErrors(async () => {
3812
+ const result = akmLint({
3813
+ fix: args.fix ?? false,
3814
+ dir: getStringArg(args, "dir"),
3815
+ });
3816
+ output("lint", result);
3817
+ if (!result.ok)
2728
3818
  process.exit(EXIT_GENERAL);
3819
+ });
3820
+ },
3821
+ });
3822
+ const improveCommand = defineCommand({
3823
+ meta: {
3824
+ name: "improve",
3825
+ description: "Analyze existing AKM assets and generate improvement proposals; also consolidates memories when profiles.improve.default.processes.consolidate.enabled is true",
3826
+ },
3827
+ args: {
3828
+ scope: {
3829
+ type: "positional",
3830
+ description: "Optional asset type or asset ref to improve",
3831
+ required: false,
3832
+ },
3833
+ task: { type: "string", description: "Add extra guidance for this improvement pass" },
3834
+ "dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
3835
+ target: { type: "string", description: "Override the write target for accepted proposals" },
3836
+ "auto-accept": {
3837
+ type: "string",
3838
+ description: "Auto-accept proposals at or above this confidence threshold (0-100). Default: disabled. Pass a value 0-100 to enable. 'safe' is an alias for 90. Pass 'false' to be explicit.",
3839
+ },
3840
+ limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
3841
+ "timeout-ms": {
3842
+ type: "string",
3843
+ description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
3844
+ },
3845
+ "ignore-cooldown": {
3846
+ type: "boolean",
3847
+ description: "Ignore all cooldown periods (equivalent to --reflect-cooldown-days 0 --distill-cooldown-days 0 --consolidate-cooldown-days 0)",
3848
+ default: false,
3849
+ },
3850
+ "reflect-cooldown-days": {
3851
+ type: "string",
3852
+ description: "Override reflect cooldown for this run only, applying uniformly to all asset types. Per-type defaults (memory=2d, lesson=7d, workflow/skill/agent/command/knowledge/script/wiki=30d, task=60d) can be configured via profiles.improve.<name>.processes.reflect.cooldownByType. Set 0 to disable.",
3853
+ },
3854
+ "distill-cooldown-days": {
3855
+ type: "string",
3856
+ description: "Override distill cooldown for this run only (default: 1, 0 to disable)",
3857
+ },
3858
+ "consolidate-cooldown-days": {
3859
+ type: "string",
3860
+ description: "Override consolidate cooldown for this run only (default: 14, 0 to disable)",
3861
+ },
3862
+ "consolidate-recovery": {
3863
+ type: "string",
3864
+ description: "How to handle stale/incomplete consolidation journals: abort (default) or clean (remove stale journal artifacts)",
3865
+ },
3866
+ "require-feedback-signal": {
3867
+ type: "boolean",
3868
+ description: "Only process assets with recent feedback signals (disables retrieval fallback)",
3869
+ default: false,
3870
+ },
3871
+ "min-retrieval-count": {
3872
+ type: "string",
3873
+ description: "Minimum retrieval count for zero-feedback fallback eligibility (default: 1, set 0 to include all assets regardless of retrieval history)",
3874
+ },
3875
+ "json-to-stdout": {
3876
+ type: "boolean",
3877
+ description: "Emit the full JSON result on stdout (legacy behaviour). (0.8.0+: full result is recorded in the improve_runs table of state.db and stdout is empty; use this flag for the prior behaviour, e.g. `akm improve --json-to-stdout | jq`.)",
3878
+ default: false,
3879
+ },
3880
+ profile: {
3881
+ type: "string",
3882
+ description: "Named improve profile from profiles.improve or built-in profiles (default, quick, thorough, memory-focus). Controls which sub-processes run and which asset types are processed.",
3883
+ },
3884
+ },
3885
+ async run({ args }) {
3886
+ await runWithJsonErrors(async () => {
3887
+ const formatFlagValue = parseFlagValue(process.argv, "--format");
3888
+ if (formatFlagValue !== undefined) {
3889
+ throw new UsageError(`akm improve does not accept --format. That flag controls output formatting for other commands (search, show, etc.).\n` +
3890
+ `Did you mean: akm improve (no --format flag)?`, "INVALID_FLAG_VALUE");
3891
+ }
3892
+ const jsonToStdout = getHyphenatedBoolean(args, "json-to-stdout");
3893
+ const autoAcceptRaw = getHyphenatedArg(args, "auto-accept");
3894
+ const autoAccept = parseAutoAcceptFlag(autoAcceptRaw);
3895
+ const targetArg = getStringArg(args, "target");
3896
+ const taskArg = getStringArg(args, "task");
3897
+ const dryRun = getHyphenatedBoolean(args, "dry-run");
3898
+ const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
3899
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
3900
+ const ignoreCooldown = getHyphenatedBoolean(args, "ignore-cooldown");
3901
+ const reflectCooldownRaw = getHyphenatedArg(args, "reflect-cooldown-days");
3902
+ const reflectCooldownDays = ignoreCooldown
3903
+ ? 0
3904
+ : parseNonNegativeIntFlag(reflectCooldownRaw, "--reflect-cooldown-days");
3905
+ const distillCooldownRaw = getHyphenatedArg(args, "distill-cooldown-days");
3906
+ const distillCooldownDays = ignoreCooldown
3907
+ ? 0
3908
+ : parseNonNegativeIntFlag(distillCooldownRaw, "--distill-cooldown-days");
3909
+ const consolidateCooldownRaw = getHyphenatedArg(args, "consolidate-cooldown-days");
3910
+ const consolidateCooldownDays = ignoreCooldown
3911
+ ? 0
3912
+ : parseNonNegativeIntFlag(consolidateCooldownRaw, "--consolidate-cooldown-days");
3913
+ const consolidateRecoveryRaw = getHyphenatedArg(args, "consolidate-recovery");
3914
+ const consolidateRecovery = consolidateRecoveryRaw === undefined
3915
+ ? undefined
3916
+ : consolidateRecoveryRaw.trim().toLowerCase();
3917
+ if (consolidateRecovery !== undefined && consolidateRecovery !== "abort" && consolidateRecovery !== "clean") {
3918
+ throw new UsageError(`Invalid --consolidate-recovery value: "${consolidateRecoveryRaw}". Must be one of: abort, clean.`, "INVALID_FLAG_VALUE");
3919
+ }
3920
+ const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
3921
+ const minRetrievalCount = parseNonNegativeIntFlag(minRetrievalCountRaw, "--min-retrieval-count");
3922
+ const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
3923
+ const profileArg = getStringArg(args, "profile");
3924
+ const improveLogFile = path.join(getCacheDir(), "logs", "improve", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
3925
+ setLogFile(improveLogFile);
3926
+ const startedAtMs = Date.now();
3927
+ let improveResult;
3928
+ try {
3929
+ improveResult = await akmImprove({
3930
+ scope: getStringArg(args, "scope"),
3931
+ task: taskArg,
3932
+ dryRun,
3933
+ target: targetArg,
3934
+ autoAccept,
3935
+ ...(limitRaw !== undefined ? { limit: limitRaw } : {}),
3936
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3937
+ ...(reflectCooldownDays !== undefined ? { reflectCooldownDays } : {}),
3938
+ ...(distillCooldownDays !== undefined ? { distillCooldownDays } : {}),
3939
+ ...(consolidateCooldownDays !== undefined ? { consolidateCooldownDays } : {}),
3940
+ ...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
3941
+ ...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
3942
+ ...(profileArg !== undefined ? { profile: profileArg } : {}),
3943
+ consolidateOptions: {
3944
+ target: targetArg,
3945
+ dryRun,
3946
+ autoAccept,
3947
+ task: taskArg,
3948
+ ...(consolidateRecovery !== undefined ? { recoveryMode: consolidateRecovery } : {}),
3949
+ },
3950
+ });
3951
+ }
3952
+ finally {
3953
+ clearLogFile();
2729
3954
  }
3955
+ const durationMs = Date.now() - startedAtMs;
3956
+ if (jsonToStdout) {
3957
+ // Legacy / escape-hatch mode: full JSON on stdout, no file write.
3958
+ // Kept for scripts/agents that already pipe to jq.
3959
+ output("improve", improveResult);
3960
+ process.exit(0);
3961
+ }
3962
+ // Default mode (0.8.0+): persist the full result as a row in the
3963
+ // `improve_runs` table of state.db (migration 003) and emit NOTHING
3964
+ // on stdout. The verbose JSON would otherwise scroll earlier progress
3965
+ // logs out of the terminal buffer. The existing `[improve] ...`
3966
+ // progress log lines on stderr remain the canonical console UX —
3967
+ // do NOT add any new console output here.
3968
+ //
3969
+ // Pre-0.8.0 wrote `<stash>/.akm/runs/<run-id>/improve-result.json`;
3970
+ // those files are no longer authored. Query recent runs with:
3971
+ // sqlite3 "$AKM_DATA_DIR/state.db" \
3972
+ // "SELECT id, started_at, ok, dry_run FROM improve_runs \
3973
+ // ORDER BY started_at DESC LIMIT 10"
3974
+ const runId = buildImproveRunId();
3975
+ const primaryStashDir = resolveSourceEntries(undefined, loadConfig())[0]?.path;
3976
+ const resultRef = relativeImproveResultPath(runId);
3977
+ if (primaryStashDir) {
3978
+ try {
3979
+ writeImproveResultFile(primaryStashDir, runId, improveResult);
3980
+ }
3981
+ catch (err) {
3982
+ // Stderr warning on the failure path is preferable to crashing
3983
+ // the run after all the work has completed.
3984
+ process.stderr.write(`warning: failed to record improve run ${resultRef}: ${err instanceof Error ? err.message : String(err)}\n`);
3985
+ }
3986
+ }
3987
+ else {
3988
+ process.stderr.write(`warning: no writable stash directory resolved; improve result not persisted to state.db (use --json-to-stdout to capture)\n`);
3989
+ }
3990
+ // durationMs reserved for future use (no console emission today).
3991
+ void durationMs;
3992
+ process.exit(0);
2730
3993
  });
2731
3994
  },
2732
3995
  });
@@ -2742,6 +4005,7 @@ const proposeCommand = defineCommand({
2742
4005
  type: { type: "positional", description: "Asset type (skill, command, knowledge, lesson, ...)", required: false },
2743
4006
  name: { type: "positional", description: "Asset name (slug or path under the type dir)", required: false },
2744
4007
  task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
4008
+ file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
2745
4009
  profile: { type: "string", description: "Override the agent profile (defaults to agent.default)" },
2746
4010
  "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
2747
4011
  },
@@ -2750,17 +4014,22 @@ const proposeCommand = defineCommand({
2750
4014
  // citty silently shows help and exits 0 when required positionals are
2751
4015
  // omitted. Re-validate explicitly so the exit code is 2 (USAGE) and a
2752
4016
  // structured JSON error reaches scripted callers.
2753
- if (!args.type || !args.name || !args.task) {
2754
- throw new UsageError("Usage: akm propose <type> <name> --task '<task>'.", "MISSING_REQUIRED_ARGUMENT", "Provide the asset type, name, and a --task description, e.g. `akm propose skill deploy --task 'Deploy a service'`.");
4017
+ const taskFromFlag = typeof args.task === "string" ? args.task : undefined;
4018
+ const fileFromFlag = typeof args.file === "string" ? args.file : undefined;
4019
+ if (!args.type || !args.name || (!taskFromFlag && !fileFromFlag)) {
4020
+ throw new UsageError("Usage: akm propose <type> <name> (--task '<task>' | --file <path>).", "MISSING_REQUIRED_ARGUMENT", "Provide the asset type, name, and exactly one of --task or --file.");
4021
+ }
4022
+ if (taskFromFlag && fileFromFlag) {
4023
+ throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
2755
4024
  }
2756
- const timeoutRaw = args["timeout-ms"];
2757
- const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
4025
+ const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
4026
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
2758
4027
  const result = await akmPropose({
2759
4028
  type: String(args.type),
2760
4029
  name: String(args.name),
2761
- task: String(args.task ?? ""),
2762
- profile: typeof args.profile === "string" && args.profile.trim() ? args.profile : undefined,
2763
- ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
4030
+ task: taskText,
4031
+ profile: getStringArg(args, "profile"),
4032
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
2764
4033
  });
2765
4034
  output("propose", result);
2766
4035
  if (result.ok === false) {
@@ -2769,6 +4038,207 @@ const proposeCommand = defineCommand({
2769
4038
  });
2770
4039
  },
2771
4040
  });
4041
+ const TASKS_SUBCOMMAND_SET = new Set([
4042
+ "add",
4043
+ "list",
4044
+ "show",
4045
+ "remove",
4046
+ "enable",
4047
+ "disable",
4048
+ "run",
4049
+ "history",
4050
+ "sync",
4051
+ "doctor",
4052
+ ]);
4053
+ const GRAPH_SUBCOMMAND_SET = new Set([
4054
+ "summary",
4055
+ "entities",
4056
+ "entity",
4057
+ "relations",
4058
+ "related",
4059
+ "orphans",
4060
+ "export",
4061
+ "update",
4062
+ ]);
4063
+ const tasksAddCommand = defineCommand({
4064
+ meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
4065
+ args: {
4066
+ id: { type: "positional", description: "Task id (used as filename and scheduler entry)", required: true },
4067
+ schedule: { type: "string", description: 'Cron-style schedule, e.g. "0 9 * * *" or "@daily"', required: true },
4068
+ workflow: { type: "string", description: "Workflow ref to invoke (e.g. workflow:my-flow)" },
4069
+ prompt: {
4070
+ type: "string",
4071
+ description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
4072
+ },
4073
+ command: {
4074
+ type: "string",
4075
+ description: 'Shell command to run on the schedule (no AI agent), e.g. "akm improve --auto-accept safe". Split on whitespace; quote the whole flag value.',
4076
+ },
4077
+ profile: { type: "string", description: "Agent profile to use for prompt targets (default: defaults.agent)" },
4078
+ params: { type: "string", description: "Workflow params as a JSON object" },
4079
+ name: { type: "string", description: "Human-readable name for the task" },
4080
+ "when-to-use": { type: "string", description: "Guidance on when this task runs or should be used" },
4081
+ description: { type: "string", description: "Human-readable description" },
4082
+ tags: { type: "string", description: "Comma-separated tags" },
4083
+ disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
4084
+ force: { type: "boolean", description: "Overwrite an existing task with the same id", default: false },
4085
+ },
4086
+ async run({ args }) {
4087
+ await runWithJsonErrors(async () => {
4088
+ const result = await akmTasksAdd({
4089
+ id: args.id,
4090
+ schedule: args.schedule,
4091
+ workflow: args.workflow,
4092
+ prompt: args.prompt,
4093
+ command: args.command,
4094
+ profile: args.profile,
4095
+ params: args.params,
4096
+ name: args.name,
4097
+ when_to_use: getHyphenatedArg(args, "when-to-use"),
4098
+ description: args.description,
4099
+ tags: args.tags
4100
+ ? args.tags
4101
+ .split(/[\s,]+/)
4102
+ .map((s) => s.trim())
4103
+ .filter(Boolean)
4104
+ : undefined,
4105
+ disabled: args.disabled === true,
4106
+ force: args.force === true,
4107
+ });
4108
+ output("tasks-add", result);
4109
+ });
4110
+ },
4111
+ });
4112
+ const tasksListCommand = defineCommand({
4113
+ meta: { name: "list", description: "List scheduled tasks in the stash" },
4114
+ async run() {
4115
+ await runWithJsonErrors(async () => {
4116
+ const result = await akmTasksList();
4117
+ output("tasks-list", result);
4118
+ });
4119
+ },
4120
+ });
4121
+ const tasksShowCommand = defineCommand({
4122
+ meta: { name: "show", description: "Show a parsed task definition" },
4123
+ args: { id: { type: "positional", description: "Task id or task:<id>", required: true } },
4124
+ async run({ args }) {
4125
+ await runWithJsonErrors(async () => {
4126
+ const { id } = parseTaskRef(args.id);
4127
+ const result = await akmTasksShow(id);
4128
+ output("tasks-show", result);
4129
+ });
4130
+ },
4131
+ });
4132
+ const tasksRemoveCommand = defineCommand({
4133
+ meta: { name: "remove", description: "Delete a task file and uninstall it from the OS scheduler" },
4134
+ args: { id: { type: "positional", description: "Task id", required: true } },
4135
+ async run({ args }) {
4136
+ await runWithJsonErrors(async () => {
4137
+ const { id } = parseTaskRef(args.id);
4138
+ const result = await akmTasksRemove(id);
4139
+ output("tasks-remove", result);
4140
+ });
4141
+ },
4142
+ });
4143
+ function makeTasksToggleCommand(enabled) {
4144
+ const verb = enabled ? "enable" : "disable";
4145
+ const description = enabled
4146
+ ? "Enable a previously-disabled task"
4147
+ : "Disable a task in the OS scheduler without removing the file";
4148
+ return defineCommand({
4149
+ meta: { name: verb, description },
4150
+ args: { id: { type: "positional", description: "Task id", required: true } },
4151
+ async run({ args }) {
4152
+ await runWithJsonErrors(async () => {
4153
+ const { id } = parseTaskRef(args.id);
4154
+ const result = await akmTasksSetEnabled(id, enabled);
4155
+ output(`tasks-${verb}`, result);
4156
+ });
4157
+ },
4158
+ });
4159
+ }
4160
+ const tasksEnableCommand = makeTasksToggleCommand(true);
4161
+ const tasksDisableCommand = makeTasksToggleCommand(false);
4162
+ const tasksRunCommand = defineCommand({
4163
+ meta: {
4164
+ name: "run",
4165
+ description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
4166
+ },
4167
+ args: { id: { type: "positional", description: "Task id", required: true } },
4168
+ async run({ args }) {
4169
+ await runWithJsonErrors(async () => {
4170
+ const { id } = parseTaskRef(args.id);
4171
+ const envelope = await akmTasksRun(id);
4172
+ output("tasks-run", envelope);
4173
+ if (envelope.exitCode !== 0)
4174
+ process.exit(envelope.exitCode);
4175
+ });
4176
+ },
4177
+ });
4178
+ const tasksHistoryCommand = defineCommand({
4179
+ meta: { name: "history", description: "Show recent task run history" },
4180
+ args: {
4181
+ id: { type: "string", description: "Filter to one task id" },
4182
+ limit: { type: "string", description: "Maximum rows to return (default 50)" },
4183
+ },
4184
+ async run({ args }) {
4185
+ await runWithJsonErrors(async () => {
4186
+ const limit = parsePositiveIntFlag(args.limit ?? undefined);
4187
+ const result = await akmTasksHistory({ id: args.id, limit });
4188
+ output("tasks-history", result);
4189
+ });
4190
+ },
4191
+ });
4192
+ const tasksSyncCommand = defineCommand({
4193
+ meta: {
4194
+ name: "sync",
4195
+ description: "Reconcile the on-disk task files with the OS scheduler",
4196
+ },
4197
+ async run() {
4198
+ await runWithJsonErrors(async () => {
4199
+ const result = await akmTasksSync();
4200
+ output("tasks-sync", result);
4201
+ });
4202
+ },
4203
+ });
4204
+ const tasksDoctorCommand = defineCommand({
4205
+ meta: {
4206
+ name: "doctor",
4207
+ description: "Report the active scheduler backend, akm bin path, log dir, and supported schedule subset",
4208
+ },
4209
+ async run() {
4210
+ await runWithJsonErrors(async () => {
4211
+ const result = await akmTasksDoctor();
4212
+ output("tasks-doctor", result);
4213
+ });
4214
+ },
4215
+ });
4216
+ const tasksCommand = defineCommand({
4217
+ meta: {
4218
+ name: "tasks",
4219
+ description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
4220
+ },
4221
+ subCommands: {
4222
+ add: tasksAddCommand,
4223
+ list: tasksListCommand,
4224
+ show: tasksShowCommand,
4225
+ remove: tasksRemoveCommand,
4226
+ enable: tasksEnableCommand,
4227
+ disable: tasksDisableCommand,
4228
+ run: tasksRunCommand,
4229
+ history: tasksHistoryCommand,
4230
+ sync: tasksSyncCommand,
4231
+ doctor: tasksDoctorCommand,
4232
+ },
4233
+ run({ args }) {
4234
+ return runWithJsonErrors(async () => {
4235
+ if (hasSubcommand(args, TASKS_SUBCOMMAND_SET))
4236
+ return;
4237
+ const result = await akmTasksList();
4238
+ output("tasks-list", result);
4239
+ });
4240
+ },
4241
+ });
2772
4242
  const main = defineCommand({
2773
4243
  meta: {
2774
4244
  name: "akm",
@@ -2777,8 +4247,21 @@ const main = defineCommand({
2777
4247
  },
2778
4248
  args: {
2779
4249
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
2780
- detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)", default: "brief" },
2781
- quiet: { type: "boolean", alias: "q", description: "Suppress stderr warnings", default: false },
4250
+ detail: {
4251
+ type: "string",
4252
+ description: "Detail level. Stable: brief|normal|full. Experimental: summary|agent " +
4253
+ "(supported on a subset of commands; coverage will expand or these will be replaced — " +
4254
+ "see STABILITY.md). Default: brief.",
4255
+ default: "brief",
4256
+ },
4257
+ quiet: {
4258
+ type: "boolean",
4259
+ alias: "q",
4260
+ description: "Suppress non-essential stderr output (banners, spinners, progress info). " +
4261
+ "Safety-critical output is never suppressed: errors, destructive-action confirmation prompts, " +
4262
+ "and auto-migration banners always appear regardless of --quiet.",
4263
+ default: false,
4264
+ },
2782
4265
  verbose: {
2783
4266
  type: "boolean",
2784
4267
  description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
@@ -2789,7 +4272,10 @@ const main = defineCommand({
2789
4272
  setup: setupCommand,
2790
4273
  init: initCommand,
2791
4274
  index: indexCommand,
4275
+ health: healthCommand,
2792
4276
  info: infoCommand,
4277
+ graph: graphCommand,
4278
+ db: dbCommand,
2793
4279
  add: addCommand,
2794
4280
  list: listCommand,
2795
4281
  remove: removeCommand,
@@ -2810,18 +4296,25 @@ const main = defineCommand({
2810
4296
  feedback: feedbackCommand,
2811
4297
  history: historyCommand,
2812
4298
  events: eventsCommand,
2813
- proposal: proposalCommand,
2814
- reflect: reflectCommand,
4299
+ lessons: lessonsCommand,
4300
+ agent: agentCommand,
4301
+ lint: lintCommand,
4302
+ improve: improveCommand,
2815
4303
  propose: proposeCommand,
2816
- distill: distillCommand,
4304
+ proposals: proposalsCommand,
4305
+ accept: acceptCommand,
4306
+ reject: rejectCommand,
4307
+ diff: diffCommand,
4308
+ revert: revertCommand,
2817
4309
  help: helpCommand,
2818
4310
  hints: hintsCommand,
2819
4311
  completions: completionsCommand,
2820
4312
  vault: vaultCommand,
2821
4313
  wiki: wikiCommand,
4314
+ tasks: tasksCommand,
2822
4315
  },
2823
4316
  });
2824
- const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "get", "set", "unset"]);
4317
+ const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
2825
4318
  const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
2826
4319
  const WIKI_SUBCOMMAND_SET = new Set([
2827
4320
  "create",
@@ -2835,10 +4328,13 @@ const WIKI_SUBCOMMAND_SET = new Set([
2835
4328
  "lint",
2836
4329
  "ingest",
2837
4330
  ]);
2838
- const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);
2839
4331
  // ── Exit codes ──────────────────────────────────────────────────────────────
2840
4332
  const EXIT_GENERAL = 1;
2841
4333
  const EXIT_USAGE = 2;
4334
+ /** `akm health` warn status — emitted when overall status is "warn" (advisories
4335
+ * fired but no hard failure). Chosen as 4 to avoid colliding with EXIT_GENERAL
4336
+ * (1 / fail) and EXIT_USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail. */
4337
+ const EXIT_HEALTH_WARN = 4;
2842
4338
  const EXIT_CONFIG = 78;
2843
4339
  // citty reads process.argv directly and does not accept a custom argv array,
2844
4340
  // so we must replace process.argv with the normalized version before runMain.
@@ -2849,18 +4345,52 @@ process.argv = normalizeShowArgv(process.argv);
2849
4345
  // invalid; surface it through the same JSON-error path the rest of the CLI uses
2850
4346
  // rather than letting the raw exception escape with a stack trace.
2851
4347
  try {
4348
+ applyEarlyStderrFlags(process.argv);
2852
4349
  initOutputMode(process.argv, loadConfig().output ?? {});
2853
4350
  }
2854
4351
  catch (error) {
2855
- const message = error instanceof Error ? error.message : String(error);
2856
- const hint = extractHint(error);
2857
- const exitCode = classifyExitCode(error);
2858
- const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
2859
- ? error.code
2860
- : undefined;
2861
- console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
2862
- process.exit(exitCode);
4352
+ emitJsonError(error);
2863
4353
  }
4354
+ // One-time cleanup of stale 0.7.x index file at the old cache location.
4355
+ // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
4356
+ // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
4357
+ // user isn't confused by a phantom DB. Best-effort; never fatal.
4358
+ try {
4359
+ const oldIndexPath = path.join(getCacheDir(), "index.db");
4360
+ if (fs.existsSync(oldIndexPath)) {
4361
+ fs.rmSync(oldIndexPath, { force: true });
4362
+ fs.rmSync(`${oldIndexPath}-shm`, { force: true });
4363
+ fs.rmSync(`${oldIndexPath}-wal`, { force: true });
4364
+ warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
4365
+ }
4366
+ }
4367
+ catch {
4368
+ // Non-fatal; one-time warning only.
4369
+ }
4370
+ // First-time-user breadcrumb: when run with no subcommand AND no config
4371
+ // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
4372
+ // above citty's auto-generated usage block. Triggers only when stdin/stderr
4373
+ // are interactive (so JSON-output users / CI consumers see nothing extra)
4374
+ // and stays silent for any flag-only invocation citty would handle itself
4375
+ // (--help, --version).
4376
+ (function maybePrintFirstTimeBanner() {
4377
+ const argv = process.argv.slice(2);
4378
+ // Fire only on completely bare `akm` invocation. Any explicit flag or
4379
+ // subcommand means the user knows what they want.
4380
+ if (argv.length > 0)
4381
+ return;
4382
+ if (!process.stderr.isTTY)
4383
+ return;
4384
+ try {
4385
+ if (fs.existsSync(getConfigPath()))
4386
+ return;
4387
+ }
4388
+ catch {
4389
+ // If we can't resolve the config path, assume non-fresh and stay silent.
4390
+ return;
4391
+ }
4392
+ console.error("👋 First time with akm? Run `akm setup` to get started.\n" + " Docs: https://github.com/itlackey/akm#readme\n");
4393
+ })();
2864
4394
  runMain(main);
2865
4395
  function classifyExitCode(error) {
2866
4396
  if (error instanceof UsageError)
@@ -2871,33 +4401,6 @@ function classifyExitCode(error) {
2871
4401
  return EXIT_GENERAL;
2872
4402
  return EXIT_GENERAL;
2873
4403
  }
2874
- async function runWithJsonErrors(fn) {
2875
- try {
2876
- // Apply --quiet flag early so warnings inside the command are suppressed
2877
- if (process.argv.includes("--quiet") || process.argv.includes("-q")) {
2878
- setQuiet(true);
2879
- }
2880
- // Apply --verbose flag early so per-spec diagnostics (gated behind
2881
- // `isVerbose()` in src/core/warn.ts) are restored. The `AKM_VERBOSE`
2882
- // env var still wins regardless — see warn.ts for the precedence rule.
2883
- if (process.argv.includes("--verbose")) {
2884
- setVerbose(true);
2885
- }
2886
- await fn();
2887
- }
2888
- catch (error) {
2889
- const message = error instanceof Error ? error.message : String(error);
2890
- const hint = extractHint(error);
2891
- const exitCode = classifyExitCode(error);
2892
- // Surface machine-readable error code from typed errors when present so
2893
- // scripts can branch on `.code` instead of message-string matching.
2894
- const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
2895
- ? error.code
2896
- : undefined;
2897
- console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
2898
- process.exit(exitCode);
2899
- }
2900
- }
2901
4404
  /**
2902
4405
  * Extract an actionable hint from an error instance. Hints live on the error
2903
4406
  * classes themselves (see src/errors.ts) — either supplied explicitly at the
@@ -2909,86 +4412,27 @@ function extractHint(error) {
2909
4412
  }
2910
4413
  return undefined;
2911
4414
  }
2912
- function hasConfigSubcommand(args) {
2913
- const command = Array.isArray(args._) ? args._[0] : undefined;
2914
- return typeof command === "string" && CONFIG_SUBCOMMAND_SET.has(command);
2915
- }
2916
- function hasVaultSubcommand(args) {
2917
- const command = Array.isArray(args._) ? args._[0] : undefined;
2918
- return typeof command === "string" && VAULT_SUBCOMMAND_SET.has(command);
2919
- }
2920
- function hasWikiSubcommand(args) {
2921
- const command = Array.isArray(args._) ? args._[0] : undefined;
2922
- return typeof command === "string" && WIKI_SUBCOMMAND_SET.has(command);
2923
- }
2924
4415
  /**
2925
- * Normalize argv so positional view-mode arguments after the asset ref
2926
- * are rewritten into internal flags that citty can parse.
2927
- *
2928
- * Converts:
2929
- * akm show knowledge:guide.md toc → akm show knowledge:guide.md --akmView toc
2930
- * akm show knowledge:guide.md section Auth → akm show knowledge:guide.md --akmView section --akmHeading Auth
2931
- * akm show knowledge:guide.md lines 1 50 → akm show knowledge:guide.md --akmView lines --akmStart 1 --akmEnd 50
2932
- *
2933
- * Legacy `--view` is intentionally unsupported.
2934
- * Returns a new array; the input is never modified.
4416
+ * Serialize an error to the standard JSON envelope and exit.
4417
+ * Used in both the startup try/catch and `runWithJsonErrors`.
2935
4418
  */
2936
- function normalizeShowArgv(argv) {
2937
- // argv[0]=bun argv[1]=script argv[2]=subcommand argv[3]=ref argv[4..]=rest
2938
- if (argv[2] !== "show")
2939
- return argv;
2940
- if (argv.includes("--view") || argv.includes("--heading") || argv.includes("--start") || argv.includes("--end")) {
2941
- throw new UsageError('Legacy show flags are no longer supported. Use positional syntax like `akm show knowledge:guide toc` or `akm show knowledge:guide section "Auth"`.');
2942
- }
2943
- // Separate global flags from positional/show-specific args
2944
- const prefix = argv.slice(0, 3); // [bun, script, show]
2945
- const rest = argv.slice(3);
2946
- const globalFlags = [];
2947
- const showArgs = [];
2948
- for (let i = 0; i < rest.length; i++) {
2949
- const arg = rest[i];
2950
- if (arg === "--quiet" || arg === "-q" || arg === "--for-agent" || arg === "--for-agent=true") {
2951
- globalFlags.push(arg);
2952
- continue;
2953
- }
2954
- if (arg.startsWith("--format=") || arg.startsWith("--detail=")) {
2955
- globalFlags.push(arg);
2956
- continue;
2957
- }
2958
- if (arg === "--format" || arg === "--detail") {
2959
- globalFlags.push(arg);
2960
- if (rest[i + 1] !== undefined) {
2961
- globalFlags.push(rest[i + 1]);
2962
- i++;
2963
- }
2964
- continue;
2965
- }
2966
- showArgs.push(arg);
2967
- }
2968
- // showArgs[0] = ref, showArgs[1] = potential view mode, showArgs[2..] = view params
2969
- const ref = showArgs[0];
2970
- const viewMode = showArgs[1];
2971
- if (!ref || !viewMode || !SHOW_VIEW_MODES.has(viewMode)) {
2972
- return argv;
2973
- }
2974
- const result = [...prefix, ref, "--akmView", viewMode];
2975
- if (viewMode === "section") {
2976
- // Next arg is the heading name; pass empty string when missing so the
2977
- // show handler can produce a clear "section not found" error.
2978
- const heading = showArgs[2] ?? "";
2979
- result.push("--akmHeading", heading);
4419
+ function emitJsonError(error) {
4420
+ const message = error instanceof Error ? error.message : String(error);
4421
+ const hint = extractHint(error);
4422
+ const exitCode = classifyExitCode(error);
4423
+ const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
4424
+ ? error.code
4425
+ : undefined;
4426
+ console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
4427
+ process.exit(exitCode);
4428
+ }
4429
+ async function runWithJsonErrors(fn) {
4430
+ try {
4431
+ await fn();
2980
4432
  }
2981
- else if (viewMode === "lines") {
2982
- // Next two args are start and end
2983
- const start = showArgs[2];
2984
- const end = showArgs[3];
2985
- if (start)
2986
- result.push("--akmStart", start);
2987
- if (end)
2988
- result.push("--akmEnd", end);
4433
+ catch (error) {
4434
+ emitJsonError(error);
2989
4435
  }
2990
- result.push(...globalFlags);
2991
- return result;
2992
4436
  }
2993
4437
  // ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
2994
4438
  function loadHints(detail = "normal") {