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
@@ -0,0 +1,158 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Shared JSON parsing utilities for LLM and agent output.
6
+ *
7
+ * Lives in `src/core/` so that both `src/llm/` and `src/integrations/agent/`
8
+ * can import without crossing the one-way boundary defined by v1 spec §9.7
9
+ * (agent/ must not import from llm/).
10
+ *
11
+ * The canonical implementation is ported from `src/llm/client.ts` (most
12
+ * complete version):
13
+ * - Strips `<think>…</think>` reasoning blocks.
14
+ * - Strips markdown code fences (``` or ~~~, optional language tag, with
15
+ * trailing spaces on the fence line).
16
+ * - Escapes unescaped control characters (actual \n, \r, \t bytes) inside
17
+ * JSON string values so `JSON.parse` succeeds on outputs from local LLMs.
18
+ * - Balanced-brace scanner handles both `{…}` and `[…]` top-level
19
+ * structures (spawn.ts v0 only handled `{…}` — that was a bug).
20
+ */
21
+ /**
22
+ * Strips `<think>…</think>` blocks from LLM output (for reasoning-capable
23
+ * models). Also strips leading/trailing whitespace.
24
+ */
25
+ export function stripThinkBlocks(raw) {
26
+ return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
27
+ }
28
+ /**
29
+ * Strips markdown code fences (``` or ~~~, with optional language tag).
30
+ * Handles fences with trailing spaces. Returns trimmed content.
31
+ */
32
+ export function stripCodeFences(raw) {
33
+ return raw
34
+ .trim()
35
+ .replace(/^```(?:json)?\s*\n?/i, "")
36
+ .replace(/\n?```\s*$/i, "")
37
+ .trim();
38
+ }
39
+ /**
40
+ * Escapes unescaped control characters (actual \n, \r, \t bytes) inside JSON
41
+ * string values. Prevents `JSON.parse` failures from embedded newlines in
42
+ * local-LLM output.
43
+ */
44
+ export function escapeJsonStringControls(raw) {
45
+ let out = "";
46
+ let inString = false;
47
+ let escaped = false;
48
+ for (let i = 0; i < raw.length; i++) {
49
+ const ch = raw[i];
50
+ if (escaped) {
51
+ out += ch;
52
+ escaped = false;
53
+ continue;
54
+ }
55
+ if (ch === "\\" && inString) {
56
+ out += ch;
57
+ escaped = true;
58
+ continue;
59
+ }
60
+ if (ch === '"') {
61
+ inString = !inString;
62
+ out += ch;
63
+ continue;
64
+ }
65
+ if (inString) {
66
+ if (ch === "\n") {
67
+ out += "\\n";
68
+ continue;
69
+ }
70
+ if (ch === "\r") {
71
+ out += "\\r";
72
+ continue;
73
+ }
74
+ if (ch === "\t") {
75
+ out += "\\t";
76
+ continue;
77
+ }
78
+ }
79
+ out += ch;
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Full pipeline: stripThinkBlocks → stripCodeFences → escapeJsonStringControls
85
+ * → JSON.parse. Returns `undefined` on parse failure.
86
+ */
87
+ export function parseJsonResponse(raw) {
88
+ try {
89
+ const cleaned = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
90
+ return JSON.parse(cleaned);
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ /**
97
+ * Attempts `parseJsonResponse` first. On failure, scans for the first
98
+ * balanced `{ }` or `[ ]` structure in the text and attempts to parse that
99
+ * substring. Returns `undefined` if no valid JSON structure is found.
100
+ *
101
+ * Non-array results are preferred: if a `{…}` object is found first, it is
102
+ * returned immediately. Arrays (`[…]`) are captured as a fallback and
103
+ * returned only when no object was found.
104
+ */
105
+ export function parseEmbeddedJsonResponse(raw) {
106
+ const direct = parseJsonResponse(raw);
107
+ if (direct !== undefined)
108
+ return direct;
109
+ const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
110
+ let arrayFallback;
111
+ for (let start = 0; start < text.length; start++) {
112
+ const opener = text[start];
113
+ if (opener !== "{" && opener !== "[")
114
+ continue;
115
+ const closer = opener === "{" ? "}" : "]";
116
+ let depth = 0;
117
+ let inString = false;
118
+ let escaped = false;
119
+ for (let i = start; i < text.length; i++) {
120
+ const ch = text[i];
121
+ if (inString) {
122
+ if (escaped) {
123
+ escaped = false;
124
+ }
125
+ else if (ch === "\\") {
126
+ escaped = true;
127
+ }
128
+ else if (ch === '"') {
129
+ inString = false;
130
+ }
131
+ continue;
132
+ }
133
+ if (ch === '"') {
134
+ inString = true;
135
+ continue;
136
+ }
137
+ if (ch === opener)
138
+ depth += 1;
139
+ if (ch === closer) {
140
+ depth -= 1;
141
+ if (depth === 0) {
142
+ try {
143
+ const parsed = JSON.parse(text.slice(start, i + 1));
144
+ if (!Array.isArray(parsed)) {
145
+ return parsed;
146
+ }
147
+ arrayFallback ??= parsed;
148
+ break;
149
+ }
150
+ catch {
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ return arrayFallback;
158
+ }
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Centralized path resolution for all akm directories.
3
6
  *
@@ -5,14 +8,93 @@
5
8
  * following XDG Base Directory conventions on Unix and standard locations
6
9
  * on Windows.
7
10
  */
11
+ import os from "node:os";
8
12
  import path from "node:path";
13
+ import { IS_WINDOWS } from "./common";
9
14
  import { ConfigError } from "./errors";
10
- const IS_WINDOWS = process.platform === "win32";
15
+ /**
16
+ * Returns true when the current process appears to be running under
17
+ * `bun test` (either via the BUN_TEST sentinel Bun sets on the test
18
+ * worker, or via the conventional NODE_ENV=test).
19
+ *
20
+ * Used by getDataDir/getStateDir to enforce that every test which
21
+ * resolves a data/state directory ALSO sets XDG_DATA_HOME / XDG_STATE_HOME
22
+ * (or the AKM_*_DIR overrides) to temp directories. Without that
23
+ * pairing, tests silently write SQLite databases, lockfiles, and task
24
+ * history into the developer's real `~/.local/share/akm` /
25
+ * `~/.local/state/akm`.
26
+ */
27
+ function isUnderBunTest(env) {
28
+ return env.BUN_TEST === "1" || env.NODE_ENV === "test";
29
+ }
30
+ /**
31
+ * Returns true when the given path is in a directory family the OS may
32
+ * reap (or that the user has clearly designated as a sandbox by virtue
33
+ * of placing it under `/tmp` or a macOS per-user temp dir). Used to
34
+ * decide whether `AKM_STASH_DIR=$tmpdir` should also isolate config +
35
+ * cache writes (so a test harness's `akm setup --yes --dir .` cannot
36
+ * silently clobber the user's `~/.config/akm/config.json`). See
37
+ * `docs/technical/incidents/2026-05-23-setup-clobbers-user-config.md`
38
+ * for the incident that motivated this.
39
+ *
40
+ * Both `/var/folders/*` and `/private/var/folders/*` are matched because
41
+ * `os.tmpdir()` on macOS may return either form depending on whether the
42
+ * caller has canonicalised the path (the realpath of `/var/folders` is
43
+ * `/private/var/folders`, but `path.resolve()` does not follow symlinks).
44
+ */
45
+ export function isTransientStashPath(p) {
46
+ return (p.startsWith("/tmp/") ||
47
+ p === "/tmp" ||
48
+ p.startsWith("/var/tmp/") ||
49
+ p === "/var/tmp" ||
50
+ p.startsWith("/private/tmp/") ||
51
+ p.startsWith("/private/var/folders/") ||
52
+ p.startsWith("/var/folders/"));
53
+ }
54
+ /**
55
+ * Build a TEST_ISOLATION_MISSING ConfigError describing which env var(s)
56
+ * must be set so the data/state path resolves into a temp dir instead of
57
+ * the user's real XDG home.
58
+ */
59
+ function testIsolationError(directoryKind) {
60
+ const xdgVar = directoryKind === "data" ? "XDG_DATA_HOME" : "XDG_STATE_HOME";
61
+ const akmVar = directoryKind === "data" ? "AKM_DATA_DIR" : "AKM_STATE_DIR";
62
+ return new ConfigError(`Refusing to resolve ${directoryKind} directory under bun test: neither ${xdgVar} nor ${akmVar} is set. ` +
63
+ "This guards against tests writing into the developer's real ~/.local/share/akm or ~/.local/state/akm. " +
64
+ `Set ${xdgVar} (or ${akmVar}) to a mktemp-d directory in this test's env block.`, "TEST_ISOLATION_MISSING");
65
+ }
11
66
  // ── Config directory ─────────────────────────────────────────────────────────
12
67
  export function getConfigDir(env = process.env, platform = process.platform) {
13
68
  const override = env.AKM_CONFIG_DIR?.trim();
14
69
  if (override)
15
70
  return override;
71
+ // Explicit XDG override wins next — tests and operators that pre-arrange
72
+ // an isolated config dir via XDG_CONFIG_HOME (or %APPDATA% on Windows)
73
+ // must be honored as set, so the AKM_STASH_DIR transient-isolation rule
74
+ // below does not silently move config away from where they pointed it.
75
+ if (platform === "win32") {
76
+ const appData = env.APPDATA?.trim();
77
+ if (appData)
78
+ return path.join(appData, "akm");
79
+ }
80
+ else {
81
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
82
+ if (xdgConfigHome)
83
+ return path.join(xdgConfigHome, "akm");
84
+ }
85
+ // Isolation safety: when AKM_STASH_DIR points at a transient/sandbox path
86
+ // (/tmp, /var/tmp, /private/var/folders) AND no explicit config dir
87
+ // override is set, route config writes into `${AKM_STASH_DIR}/.akm`
88
+ // instead of the user's host ~/.config/akm. This prevents the documented
89
+ // isolation pattern
90
+ // AKM_DATA_DIR=/tmp/x AKM_STASH_DIR=/tmp/x akm setup --yes --dir .
91
+ // from silently clobbering the host config. See
92
+ // docs/technical/incidents/2026-05-23-setup-clobbers-user-config.md for the incident.
93
+ // Daily users with a persistent AKM_STASH_DIR=~/my-stash are unaffected.
94
+ const stashOverride = env.AKM_STASH_DIR?.trim();
95
+ if (stashOverride && isTransientStashPath(stashOverride)) {
96
+ return path.join(stashOverride, ".akm");
97
+ }
16
98
  if (platform === "win32") {
17
99
  const appData = env.APPDATA?.trim();
18
100
  if (appData)
@@ -40,6 +122,11 @@ export function getCacheDir() {
40
122
  const override = process.env.AKM_CACHE_DIR?.trim();
41
123
  if (override)
42
124
  return override;
125
+ // Explicit XDG/platform overrides win before the transient-stash isolation
126
+ // rule below — tests and operators that pre-arrange XDG_CACHE_HOME (or
127
+ // %LOCALAPPDATA% / %USERPROFILE% / %APPDATA% on Windows) must be honored
128
+ // as set, so the AKM_STASH_DIR transient rule does not silently move cache
129
+ // writes away from where they pointed them.
43
130
  if (IS_WINDOWS) {
44
131
  const localAppData = process.env.LOCALAPPDATA?.trim();
45
132
  if (localAppData)
@@ -48,28 +135,150 @@ export function getCacheDir() {
48
135
  if (userProfile)
49
136
  return path.join(userProfile, "AppData", "Local", "akm");
50
137
  const appData = process.env.APPDATA?.trim();
51
- if (!appData) {
52
- throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
138
+ if (appData) {
139
+ // Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so
140
+ // navigate to the sibling "Local" directory. This is typically
141
+ // C:\Users\<name>\AppData\Roaming → C:\Users\<name>\AppData\Local\akm.
142
+ // Preferred: set LOCALAPPDATA to avoid this navigation.
143
+ return path.join(appData, "..", "Local", "akm");
53
144
  }
54
- // Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so navigate
55
- // to the sibling "Local" directory. This is typically
56
- // C:\Users\<name>\AppData\Roaming C:\Users\<name>\AppData\Local\akm.
57
- // Preferred: set LOCALAPPDATA to avoid this navigation.
58
- return path.join(appData, "..", "Local", "akm");
59
- }
60
- const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
61
- if (xdgCacheHome)
62
- return path.join(xdgCacheHome, "akm");
145
+ }
146
+ else {
147
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
148
+ if (xdgCacheHome)
149
+ return path.join(xdgCacheHome, "akm");
150
+ }
151
+ // Isolation safety (mirrors getConfigDir): when AKM_STASH_DIR points at a
152
+ // transient path AND no explicit cache override is set, route cache writes
153
+ // into `${AKM_STASH_DIR}/.akm/cache` so that config backups, registry-index
154
+ // cache, and other regenerable artifacts do not pollute the user's host
155
+ // ~/.cache/akm directory.
156
+ const stashOverride = process.env.AKM_STASH_DIR?.trim();
157
+ if (stashOverride && isTransientStashPath(stashOverride)) {
158
+ return path.join(stashOverride, ".akm", "cache");
159
+ }
160
+ if (IS_WINDOWS) {
161
+ // None of LOCALAPPDATA / USERPROFILE / APPDATA were set above.
162
+ throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
163
+ }
63
164
  const home = process.env.HOME?.trim();
64
165
  if (!home)
65
166
  return path.join("/tmp", "akm-cache");
66
167
  return path.join(home, ".cache", "akm");
67
168
  }
169
+ // ── Data directory ───────────────────────────────────────────────────────────
170
+ /**
171
+ * Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
172
+ * `%LOCALAPPDATA%\akm\data` on Windows).
173
+ *
174
+ * Holds durable, non-regenerable application data: SQLite databases
175
+ * (index.db, workflow.db, state.db), akm.lock, and config-backups.
176
+ * Losing this directory loses history and installed state.
177
+ *
178
+ * Env overrides (in priority order):
179
+ * AKM_DATA_DIR — point to any directory
180
+ * XDG_DATA_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
181
+ */
182
+ export function getDataDir(env = process.env, platform = process.platform) {
183
+ const override = env.AKM_DATA_DIR?.trim();
184
+ if (override)
185
+ return override;
186
+ // Defense-in-depth: under `bun test`, refuse to fall through to the
187
+ // user's real $XDG_DATA_HOME / ~/.local/share/akm under any condition.
188
+ // Any test that needs a data dir must point it at a mktemp-d directory
189
+ // via XDG_DATA_HOME (or AKM_DATA_DIR). The previous carve-out that only
190
+ // fired when AKM_STASH_DIR was set was a loophole: tests calling
191
+ // openDatabase() or getDbPath() without overriding any env var silently
192
+ // wrote into ~/.local/share/akm/index.db (observed: 4,183-row
193
+ // registry-cache pollution). Item 5 of the 0.8.x critical-review plan.
194
+ if (isUnderBunTest(env) && !env.XDG_DATA_HOME?.trim()) {
195
+ throw testIsolationError("data");
196
+ }
197
+ if (platform === "win32") {
198
+ const localAppData = env.LOCALAPPDATA?.trim();
199
+ if (localAppData)
200
+ return path.join(localAppData, "akm", "data");
201
+ const userProfile = env.USERPROFILE?.trim();
202
+ if (userProfile)
203
+ return path.join(userProfile, "AppData", "Local", "akm", "data");
204
+ const appData = env.APPDATA?.trim();
205
+ if (!appData) {
206
+ throw new ConfigError("Unable to determine data directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
207
+ }
208
+ return path.join(appData, "..", "Local", "akm", "data");
209
+ }
210
+ const xdgDataHome = env.XDG_DATA_HOME?.trim();
211
+ if (xdgDataHome)
212
+ return path.join(xdgDataHome, "akm");
213
+ const home = env.HOME?.trim();
214
+ if (!home)
215
+ return path.join("/tmp", "akm-data");
216
+ return path.join(home, ".local", "share", "akm");
217
+ }
218
+ // ── State directory ──────────────────────────────────────────────────────────
219
+ /**
220
+ * Returns the XDG state directory for akm (`~/.local/state/akm` on Linux/macOS,
221
+ * `%LOCALAPPDATA%\akm\state` on Windows).
222
+ *
223
+ * Holds runtime state and log-like files that persist across reboots but are
224
+ * less precious than $DATA: task history JSONL files, akm.lock.lck sentinel.
225
+ *
226
+ * Env overrides (in priority order):
227
+ * AKM_STATE_DIR — point to any directory
228
+ * XDG_STATE_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
229
+ */
230
+ export function getStateDir(env = process.env, platform = process.platform) {
231
+ const override = env.AKM_STATE_DIR?.trim();
232
+ if (override)
233
+ return override;
234
+ // Defense-in-depth: under `bun test`, refuse to fall through to the
235
+ // user's real $XDG_STATE_HOME / ~/.local/state/akm under any condition.
236
+ // See getDataDir above for rationale.
237
+ if (isUnderBunTest(env) && !env.XDG_STATE_HOME?.trim()) {
238
+ throw testIsolationError("state");
239
+ }
240
+ if (platform === "win32") {
241
+ const localAppData = env.LOCALAPPDATA?.trim();
242
+ if (localAppData)
243
+ return path.join(localAppData, "akm", "state");
244
+ const userProfile = env.USERPROFILE?.trim();
245
+ if (userProfile)
246
+ return path.join(userProfile, "AppData", "Local", "akm", "state");
247
+ const appData = env.APPDATA?.trim();
248
+ if (!appData) {
249
+ throw new ConfigError("Unable to determine state directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
250
+ }
251
+ return path.join(appData, "..", "Local", "akm", "state");
252
+ }
253
+ const xdgStateHome = env.XDG_STATE_HOME?.trim();
254
+ if (xdgStateHome)
255
+ return path.join(xdgStateHome, "akm");
256
+ const home = env.HOME?.trim();
257
+ if (!home)
258
+ return path.join("/tmp", "akm-state");
259
+ return path.join(home, ".local", "state", "akm");
260
+ }
68
261
  export function getDbPath() {
69
- return path.join(getCacheDir(), "index.db");
262
+ return path.join(getDataDir(), "index.db");
70
263
  }
71
264
  export function getWorkflowDbPath() {
72
- return path.join(getCacheDir(), "workflow.db");
265
+ return path.join(getDataDir(), "workflow.db");
266
+ }
267
+ /** Path to the state.db file in $DATA. */
268
+ export function getStateDbPathInDataDir() {
269
+ return path.join(getDataDir(), "state.db");
270
+ }
271
+ /** Path for the task history directory in $STATE (v2 location). */
272
+ export function getTaskHistoryStateDir() {
273
+ return path.join(getStateDir(), "tasks", "history");
274
+ }
275
+ /** Path to the akm.lock file in $DATA. */
276
+ export function getLockfilePath() {
277
+ return path.join(getDataDir(), "akm.lock");
278
+ }
279
+ /** Path to the akm.lock.lck write-sentinel in $DATA. */
280
+ export function getLockfileLockPath() {
281
+ return path.join(getDataDir(), "akm.lock.lck");
73
282
  }
74
283
  export function getSemanticStatusPath() {
75
284
  return path.join(getCacheDir(), "semantic-status.json");
@@ -83,6 +292,13 @@ export function getRegistryIndexCacheDir() {
83
292
  export function getBinDir() {
84
293
  return path.join(getCacheDir(), "bin");
85
294
  }
295
+ // ── Scheduled-task runtime directories (logs + history) ──────────────────────
296
+ export function getTaskLogDir() {
297
+ return path.join(getCacheDir(), "tasks", "logs");
298
+ }
299
+ export function getTaskHistoryDir() {
300
+ return path.join(getCacheDir(), "tasks", "history");
301
+ }
86
302
  // ── Default stash directory ──────────────────────────────────────────────────
87
303
  export function getDefaultStashDir() {
88
304
  const override = process.env.AKM_STASH_DIR?.trim();
@@ -100,3 +316,99 @@ export function getDefaultStashDir() {
100
316
  }
101
317
  return path.join(home, "akm");
102
318
  }
319
+ // ── Stash directory safety check (#473) ──────────────────────────────────────
320
+ /**
321
+ * Refuse stashDir values that would clobber a sensitive system path or the
322
+ * user's home directory itself. Called from `akm init`, `akm setup`, and the
323
+ * setup-wizard validator before any disk write.
324
+ *
325
+ * Refuses:
326
+ * - The filesystem root (`/` or Windows drive root `C:\`)
327
+ * - Common system roots (`/etc`, `/var`, `/usr`, `/usr/local`, `/opt`,
328
+ * `/sys`, `/proc`, `/boot`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/dev`,
329
+ * `/run`, `/home`, `/root`, `/mnt`, `/media`,
330
+ * `/Library`, `/System`, `/Applications`)
331
+ * - The user's home directory itself (exact match — subdirs are fine)
332
+ * - User-data dotfile parents: `~/.config`, `~/.local`, `~/.cache`,
333
+ * `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, `~/.docker`,
334
+ * and the macOS/Windows `~/Documents` and `~/Downloads` parents
335
+ *
336
+ * Subdirectories of any refused path are allowed (so `~/.local/share/akm-test`
337
+ * is fine even though `~/.local` is refused). This catches fat-finger
338
+ * `--dir /` or `--dir ~` without preventing legitimate nested use.
339
+ */
340
+ export function assertSafeStashDir(stashDir) {
341
+ const resolved = path.resolve(stashDir);
342
+ // Filesystem root — POSIX and Windows drive roots.
343
+ if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
344
+ throw new ConfigError(`Refusing stashDir at filesystem root (${resolved}). Pick a subdirectory like ~/akm.`, "UNSAFE_STASH_DIR");
345
+ }
346
+ // System directories — exact match only.
347
+ const SYSTEM_ROOTS = new Set([
348
+ "/etc",
349
+ "/var",
350
+ "/var/tmp",
351
+ "/usr",
352
+ "/usr/local",
353
+ "/opt",
354
+ "/sys",
355
+ "/proc",
356
+ "/boot",
357
+ "/bin",
358
+ "/sbin",
359
+ "/lib",
360
+ "/lib64",
361
+ "/dev",
362
+ "/run",
363
+ "/home",
364
+ "/root",
365
+ "/mnt",
366
+ "/media",
367
+ "/Library",
368
+ "/System",
369
+ "/Applications",
370
+ ]);
371
+ if (SYSTEM_ROOTS.has(resolved)) {
372
+ throw new ConfigError(`Refusing stashDir at system path (${resolved}). Pick a path inside your home directory.`, "UNSAFE_STASH_DIR");
373
+ }
374
+ // User home — exact match only. Subdirs (~/akm, ~/work/stash) are fine.
375
+ // Check BOTH the env-controlled home and the OS-reported home, so the
376
+ // refusal can't be bypassed by unsetting HOME, and so it still fires
377
+ // under bun test (which isolates HOME to a tempdir while os.homedir()
378
+ // still returns the real user's home).
379
+ const candidateHomes = new Set();
380
+ const envHome = (process.env.HOME ?? process.env.USERPROFILE)?.trim();
381
+ if (envHome)
382
+ candidateHomes.add(path.resolve(envHome));
383
+ try {
384
+ const osHome = os.homedir();
385
+ if (osHome)
386
+ candidateHomes.add(path.resolve(osHome));
387
+ }
388
+ catch {
389
+ // os.homedir() can throw on misconfigured systems; ignore.
390
+ }
391
+ const HIDDEN_USER_PARENTS = [
392
+ ".config",
393
+ ".local",
394
+ ".cache",
395
+ ".ssh",
396
+ ".gnupg",
397
+ ".aws",
398
+ ".kube",
399
+ ".docker",
400
+ "Documents",
401
+ "Downloads",
402
+ "AppData",
403
+ ];
404
+ for (const home of candidateHomes) {
405
+ if (resolved === home) {
406
+ throw new ConfigError(`Refusing stashDir at your home directory (${resolved}). Pick a subdirectory like ~/akm.`, "UNSAFE_STASH_DIR");
407
+ }
408
+ for (const sub of HIDDEN_USER_PARENTS) {
409
+ if (resolved === path.join(home, sub)) {
410
+ throw new ConfigError(`Refusing stashDir at sensitive user directory (${resolved}). Pick a subdirectory or a dedicated workspace.`, "UNSAFE_STASH_DIR");
411
+ }
412
+ }
413
+ }
414
+ }