akm-cli 0.9.0-rc.1 → 0.9.0-rc.2

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 (228) hide show
  1. package/CHANGELOG.md +223 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/stash-skeleton/README.md +28 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  23. package/dist/assets/tasks/core/backup.yml +1 -0
  24. package/dist/assets/tasks/core/extract.yml +1 -0
  25. package/dist/assets/tasks/core/improve.yml +1 -0
  26. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  27. package/dist/assets/tasks/core/sync.yml +1 -0
  28. package/dist/assets/tasks/core/version-check.yml +1 -0
  29. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  30. package/dist/assets/templates/html/health.html +5 -1
  31. package/dist/cli/config-migrate.js +31 -138
  32. package/dist/cli/config-validate.js +10 -8
  33. package/dist/cli.js +48 -14
  34. package/dist/commands/agent/agent-dispatch.js +17 -16
  35. package/dist/commands/agent/agent-support.js +0 -24
  36. package/dist/commands/agent/contribute-cli.js +5 -15
  37. package/dist/commands/backup-cli.js +54 -0
  38. package/dist/commands/config-cli.js +45 -159
  39. package/dist/commands/env/secret.js +8 -5
  40. package/dist/commands/health/checks.js +130 -83
  41. package/dist/commands/health/html-report.js +4 -0
  42. package/dist/commands/health/improve-metrics.js +30 -32
  43. package/dist/commands/health/llm-usage.js +19 -19
  44. package/dist/commands/health/md-report.js +4 -0
  45. package/dist/commands/health/metrics.js +2 -1
  46. package/dist/commands/health/surfaces.js +5 -4
  47. package/dist/commands/health.js +1 -1
  48. package/dist/commands/improve/consolidate/chunking.js +2 -2
  49. package/dist/commands/improve/consolidate.js +28 -25
  50. package/dist/commands/improve/distill/promote-memory.js +5 -12
  51. package/dist/commands/improve/distill/quality-gate.js +5 -7
  52. package/dist/commands/improve/distill.js +16 -5
  53. package/dist/commands/improve/eligibility.js +22 -12
  54. package/dist/commands/improve/extract-cli.js +47 -19
  55. package/dist/commands/improve/extract.js +110 -62
  56. package/dist/commands/improve/improve-cli.js +38 -16
  57. package/dist/commands/improve/improve-result-file.js +30 -24
  58. package/dist/commands/improve/improve-strategies.js +137 -0
  59. package/dist/commands/improve/improve.js +60 -30
  60. package/dist/commands/improve/locks.js +66 -45
  61. package/dist/commands/improve/loop-stages.js +75 -33
  62. package/dist/commands/improve/memory/memory-belief.js +79 -7
  63. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  64. package/dist/commands/improve/preparation.js +71 -73
  65. package/dist/commands/improve/procedural.js +3 -2
  66. package/dist/commands/improve/recombine.js +2 -1
  67. package/dist/commands/improve/reflect.js +119 -214
  68. package/dist/commands/improve/shared.js +11 -5
  69. package/dist/commands/lint/base-linter.js +152 -42
  70. package/dist/commands/mv-cli.js +809 -0
  71. package/dist/commands/proposal/proposal-cli.js +18 -8
  72. package/dist/commands/proposal/propose.js +64 -69
  73. package/dist/commands/read/knowledge.js +436 -4
  74. package/dist/commands/read/remember-cli.js +39 -2
  75. package/dist/commands/read/search-cli.js +6 -1
  76. package/dist/commands/registry-cli.js +29 -14
  77. package/dist/commands/remember.js +2 -0
  78. package/dist/commands/sources/init.js +13 -14
  79. package/dist/commands/sources/schema-repair.js +2 -4
  80. package/dist/commands/sources/source-add.js +62 -73
  81. package/dist/commands/sources/source-manage.js +50 -46
  82. package/dist/commands/sources/stash-cli.js +41 -4
  83. package/dist/commands/tasks/default-tasks.js +12 -12
  84. package/dist/commands/tasks/tasks-cli.js +7 -3
  85. package/dist/commands/tasks/tasks.js +113 -18
  86. package/dist/commands/wiki-cli.js +9 -10
  87. package/dist/core/asset/frontmatter.js +12 -2
  88. package/dist/core/common.js +5 -3
  89. package/dist/core/config/config-io.js +28 -17
  90. package/dist/core/config/config-schema.js +358 -66
  91. package/dist/core/config/config-types.js +3 -3
  92. package/dist/core/config/config-version.js +29 -0
  93. package/dist/core/config/config-walker.js +98 -27
  94. package/dist/core/config/config.js +132 -266
  95. package/dist/core/config/deep-merge.js +41 -0
  96. package/dist/core/config/engine-semantics.js +32 -0
  97. package/dist/core/errors.js +2 -2
  98. package/dist/core/extra-params.js +61 -0
  99. package/dist/core/file-lock.js +201 -56
  100. package/dist/core/improve-result.js +178 -0
  101. package/dist/core/maintenance-barrier.js +119 -0
  102. package/dist/core/migration-backup.js +416 -0
  103. package/dist/core/paths.js +3 -0
  104. package/dist/core/redaction.js +358 -0
  105. package/dist/core/state/migrations.js +17 -2
  106. package/dist/core/state-db.js +44 -1
  107. package/dist/indexer/db/db.js +116 -1
  108. package/dist/indexer/graph/graph-extraction.js +28 -16
  109. package/dist/indexer/index-writer-lock.js +31 -24
  110. package/dist/indexer/index-written-assets.js +15 -6
  111. package/dist/indexer/indexer.js +47 -2
  112. package/dist/indexer/passes/memory-inference.js +10 -6
  113. package/dist/indexer/passes/metadata.js +250 -0
  114. package/dist/indexer/search/db-search.js +111 -44
  115. package/dist/indexer/search/fts-query.js +41 -0
  116. package/dist/indexer/search/ranking-contributors.js +48 -0
  117. package/dist/indexer/search/ranking.js +36 -23
  118. package/dist/indexer/search/search-fields.js +11 -1
  119. package/dist/integrations/agent/builder-shared.js +7 -0
  120. package/dist/integrations/agent/builders.js +5 -52
  121. package/dist/integrations/agent/config.js +3 -143
  122. package/dist/integrations/agent/detect.js +17 -2
  123. package/dist/integrations/agent/engine-resolution.js +202 -0
  124. package/dist/integrations/agent/index.js +1 -2
  125. package/dist/integrations/agent/model-aliases.js +7 -2
  126. package/dist/integrations/agent/profiles.js +6 -99
  127. package/dist/integrations/agent/runner-dispatch.js +76 -13
  128. package/dist/integrations/agent/runner.js +76 -207
  129. package/dist/integrations/agent/spawn.js +4 -6
  130. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  131. package/dist/integrations/harnesses/aider/index.js +0 -1
  132. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  133. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  134. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  135. package/dist/integrations/harnesses/claude/index.js +0 -2
  136. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  137. package/dist/integrations/harnesses/codex/index.js +0 -1
  138. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  139. package/dist/integrations/harnesses/copilot/index.js +0 -1
  140. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  141. package/dist/integrations/harnesses/gemini/index.js +0 -1
  142. package/dist/integrations/harnesses/index.js +1 -24
  143. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  144. package/dist/integrations/harnesses/opencode/index.js +0 -6
  145. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  146. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  147. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  148. package/dist/integrations/harnesses/openhands/index.js +0 -1
  149. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  150. package/dist/integrations/harnesses/pi/index.js +0 -1
  151. package/dist/integrations/harnesses/types.js +1 -32
  152. package/dist/integrations/lockfile.js +32 -21
  153. package/dist/llm/client.js +48 -14
  154. package/dist/llm/feature-gate.js +15 -47
  155. package/dist/llm/graph-extract.js +1 -1
  156. package/dist/llm/index-passes.js +8 -42
  157. package/dist/llm/memory-infer-impl.js +1 -1
  158. package/dist/llm/usage-persist.js +4 -0
  159. package/dist/llm/usage-telemetry.js +35 -5
  160. package/dist/output/shapes/helpers.js +2 -1
  161. package/dist/output/shapes/passthrough.js +2 -0
  162. package/dist/output/text/helpers.js +3 -1
  163. package/dist/schemas/akm-config.json +11013 -8600
  164. package/dist/schemas/akm-task.json +87 -0
  165. package/dist/schemas/akm-workflow.json +57 -13
  166. package/dist/scripts/migrate-storage.js +8591 -509
  167. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
  168. package/dist/setup/detected-engines.js +142 -0
  169. package/dist/setup/engine-config.js +89 -0
  170. package/dist/setup/setup.js +236 -132
  171. package/dist/setup/steps/connection.js +61 -32
  172. package/dist/setup/steps/platforms.js +4 -4
  173. package/dist/setup/steps.js +3 -2
  174. package/dist/storage/database.js +13 -1
  175. package/dist/storage/engines/sqlite-migrations.js +1 -0
  176. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  177. package/dist/storage/repositories/task-history-repository.js +78 -0
  178. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  179. package/dist/tasks/parser.js +138 -52
  180. package/dist/tasks/runner.js +71 -75
  181. package/dist/tasks/schema.js +1 -1
  182. package/dist/tasks/validator.js +11 -6
  183. package/dist/wiki/wiki.js +9 -8
  184. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  185. package/dist/workflows/concurrency-policy.js +15 -0
  186. package/dist/workflows/db.js +65 -13
  187. package/dist/workflows/exec/brief.js +18 -24
  188. package/dist/workflows/exec/frozen-judge.js +47 -0
  189. package/dist/workflows/exec/native-executor.js +124 -65
  190. package/dist/workflows/exec/report.js +93 -33
  191. package/dist/workflows/exec/run-workflow.js +38 -25
  192. package/dist/workflows/exec/scheduler.js +12 -41
  193. package/dist/workflows/exec/step-work.js +91 -35
  194. package/dist/workflows/ir/compile.js +13 -26
  195. package/dist/workflows/ir/freeze.js +243 -0
  196. package/dist/workflows/ir/plan-hash.js +40 -5
  197. package/dist/workflows/ir/schema.js +542 -1
  198. package/dist/workflows/parser.js +7 -0
  199. package/dist/workflows/program/parser.js +132 -23
  200. package/dist/workflows/program/project.js +3 -4
  201. package/dist/workflows/program/schema.js +2 -2
  202. package/dist/workflows/resource-limits.js +20 -0
  203. package/dist/workflows/runtime/plan-classifier.js +187 -0
  204. package/dist/workflows/runtime/runs.js +54 -47
  205. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  206. package/dist/workflows/validator.js +25 -0
  207. package/docs/data-and-telemetry.md +2 -2
  208. package/docs/migration/release-notes/0.6.0.md +1 -1
  209. package/docs/migration/release-notes/0.7.0.md +5 -4
  210. package/docs/migration/v0.8-to-v0.9.md +401 -0
  211. package/package.json +4 -2
  212. package/schemas/akm-config.json +16638 -0
  213. package/schemas/akm-task.json +87 -0
  214. package/schemas/akm-workflow.json +372 -0
  215. package/dist/commands/improve/improve-profiles.js +0 -168
  216. package/dist/core/config/config-migration.js +0 -602
  217. package/dist/core/deep-merge.js +0 -38
  218. package/dist/llm/call-ai.js +0 -62
  219. package/dist/setup/legacy-config.js +0 -106
  220. package/docs/README.md +0 -104
  221. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  222. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  223. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  224. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  225. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  226. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  227. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  228. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -0,0 +1,809 @@
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
+ * `akm mv <ref> <new-name>` — rename an asset within its type directory
6
+ * (SPEC-7, stash-conventions-code-spec.md).
7
+ *
8
+ * The stash organization conventions' forced-rename procedure ("grep and fix
9
+ * inbound xrefs in the same pass") is agent-executable EXCEPT for the part
10
+ * only the CLI can do: a rename mints a new `entries` row (entry_key is
11
+ * UNIQUE), which orphans the utility_scores / utility_scores_scoped /
12
+ * embeddings rows keyed by entry_id, detaches usage_events keyed by the old
13
+ * entry_ref, and strands the state.db asset_salience / asset_outcome rows
14
+ * keyed by `asset_ref` TEXT — the "rename resets learned ranking" cost the
15
+ * conventions warn about. This verb does the whole pass: move the file,
16
+ * rewrite inbound refs, re-key the index row IN PLACE (including the
17
+ * usage_events entry_ref history), and re-key the state.db salience/outcome
18
+ * rows so the accumulated history survives.
19
+ *
20
+ * Scope (v1, Experimental — see STABILITY.md):
21
+ * - flat-markdown asset types only ({@link MV_SUPPORTED_TYPES});
22
+ * - the primary writable stash only (no `--target`);
23
+ * - the source ref may be the canonical spelling or its deterministic
24
+ * `.md`-suffixed / `local//`-prefixed alias (both are canonicalized
25
+ * before anything is keyed off them) — lint-resolver FALLBACK spellings
26
+ * are rejected with the canonical ref named (see
27
+ * {@link resolveMoveSourcePath});
28
+ * - wiki refs are rejected (wikis have their own xref + lint system);
29
+ * - a memory's `.derived.md` twin moves together and keeps its
30
+ * `entry_key === <base entry_key> + ".derived"` coupling; a twin ref
31
+ * cannot be moved alone, and target names ending `.derived` are rejected
32
+ * (reserved suffix).
33
+ *
34
+ * Ordering (partial-failure mitigation — the operation spans FS + DB and is
35
+ * NOT transactional): validate everything first, compute the full rewrite
36
+ * plan, create the target's parent directory (so a blocked target aborts
37
+ * before any citer is edited), apply the citer edits, rename the file(s)
38
+ * LAST among the FS steps, then re-key the index. The command is RE-RUNNABLE after an interruption:
39
+ * a crash before the rename leaves the source resolvable under its old ref
40
+ * (already-edited citers simply yield zero further rewrites on the retry);
41
+ * a crash after the rename but before the index re-key is healed by the next
42
+ * full `akm index` (at the cost of the utility history the re-key preserves).
43
+ * Graph tables (graph_files) key extractions by file path and stay stale
44
+ * until the next graph pass — acceptable, the graph is a derived cache.
45
+ */
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+ import { defineJsonCommand, output } from "../cli/shared.js";
49
+ import { parseAssetRef, refToString } from "../core/asset/asset-ref.js";
50
+ import { deriveCanonicalAssetNameFromStashRoot, TYPE_DIRS } from "../core/asset/asset-spec.js";
51
+ import { isWithin, resolveStashDir, toPosix } from "../core/common.js";
52
+ import { UsageError } from "../core/errors.js";
53
+ import { appendEvent } from "../core/events.js";
54
+ import { getDbPath } from "../core/paths.js";
55
+ import { getStateDbPath } from "../core/state-db.js";
56
+ import { warnVerbose } from "../core/warn.js";
57
+ import { closeDatabase, openExistingDatabase, rebuildFts, rekeyEntryInPlace } from "../indexer/db/db.js";
58
+ import { indexWrittenAssets, WRITE_PATH_INDEX_BUSY_TIMEOUT_MS } from "../indexer/index-written-assets.js";
59
+ import { resolveSourceEntries } from "../indexer/search/search-source.js";
60
+ import { openDatabase } from "../storage/database.js";
61
+ import { REF_BOUNDARY_PREFIX_CLASS_SRC, REF_SLUG_CHAR_CLASS_SRC, refToRelPath, resolveRefPathInStash, } from "./lint/base-linter.js";
62
+ // ── Scope ─────────────────────────────────────────────────────────────────────
63
+ /**
64
+ * Asset types `akm mv` can rename in v1: exactly the types whose canonical
65
+ * layout is one flat `.md` file per name (the `markdownSpec` family), so a
66
+ * rename is a single-file move and inbound refs are rewritable by complete-ref
67
+ * matching. Deliberately excluded:
68
+ * - `wiki` — wikis carry their own xref + lint system (`akm wiki lint`);
69
+ * - `skill` — the canonical layout is a multi-file `skills/<name>/SKILL.md`
70
+ * directory (a directory rename, out of v1 scope);
71
+ * - `script` — unresolvable by the slug resolver (contract-pinned);
72
+ * - `workflow` — workflows may live as `.yaml`/`.yml` programs
73
+ * (`WORKFLOW_EXTENSIONS`), and `workflowSpec.toAssetPath` resolves them
74
+ * by a cwd-relative existence probe with a `<name>.md` fallback — a mv
75
+ * would either rename a YAML program to `.md` (misclassifying it) or
76
+ * fail to resolve it from any cwd but the stash root. Out of v1 scope;
77
+ * rejected with a dedicated error naming the manual procedure;
78
+ * - `task` / `env` / `secret` — not markdown assets.
79
+ */
80
+ const MV_SUPPORTED_TYPES = ["memory", "knowledge", "command", "agent", "lesson", "session", "fact"];
81
+ // ── Ref rewriting ─────────────────────────────────────────────────────────────
82
+ /**
83
+ * Boundary grammar IMPORTED from lint's `REF_RE` fragments (base-linter.ts
84
+ * `REF_BOUNDARY_PREFIX_CLASS_SRC` / `REF_SLUG_CHAR_CLASS_SRC`) so the two
85
+ * grammars cannot drift: a ref starts at line start or after whitespace /
86
+ * backtick / quote / `(` / `[` / `,` (the `[` admits flow-style YAML lists
87
+ * like `xrefs: [memory:foo]` and bracketed body refs; the `,` admits the
88
+ * refs after the first in a NO-SPACE flow list `[memory:a,memory:b]`), and
89
+ * its slug runs until
90
+ * the first non-slug character. Complete-ref matching is what keeps a longer
91
+ * ref sharing the old ref as a prefix (e.g. `memory:a/base-note-extra` when
92
+ * moving `memory:a/base-note`) untouched.
93
+ */
94
+ const REF_PREFIX_SRC = `(^|${REF_BOUNDARY_PREFIX_CLASS_SRC})`;
95
+ const REF_SUFFIX_SRC = `(?!${REF_SLUG_CHAR_CLASS_SRC})`;
96
+ function escapeRegExp(text) {
97
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
+ }
99
+ /**
100
+ * Build the rewrite patterns for one old ref: the canonical spelling plus
101
+ * the DETERMINISTIC alias spellings the resolver stack accepts for the same
102
+ * asset — the `.md`-suffixed form (`markdownSpec.toAssetPath` accepts both)
103
+ * and the `local//`-prefixed form (`makeAssetRef`/`parseAssetRef` round-trip
104
+ * it). All alias spellings rewrite to the NEW CANONICAL ref. When the source
105
+ * is a base memory, the optional `.derived` tail also rewrites explicit twin
106
+ * refs (`memory:a/note.derived` → `memory:a/new.derived`) — the twin file
107
+ * moves together, so its ref must too. The tail group is empty-capturing
108
+ * otherwise so the replacer's group indices stay stable.
109
+ */
110
+ function buildRewritePatterns(fromRef, includeDerivedTail) {
111
+ const tail = includeDerivedTail ? "(\\.derived)?" : "()";
112
+ const core = escapeRegExp(fromRef);
113
+ return [
114
+ new RegExp(`${REF_PREFIX_SRC}${core}${tail}${REF_SUFFIX_SRC}`, "gm"),
115
+ new RegExp(`${REF_PREFIX_SRC}${core}${tail}\\.md${REF_SUFFIX_SRC}`, "gm"),
116
+ new RegExp(`${REF_PREFIX_SRC}local//${core}${tail}(?:\\.md)?${REF_SUFFIX_SRC}`, "gm"),
117
+ ];
118
+ }
119
+ function buildRewriteContext(opts) {
120
+ return {
121
+ patterns: buildRewritePatterns(opts.fromRef, opts.isBaseMemory),
122
+ aliasScan: new RegExp(`(^|${REF_BOUNDARY_PREFIX_CLASS_SRC})((?:local//)?${escapeRegExp(opts.type)}:${REF_SLUG_CHAR_CLASS_SRC}+)`, "gm"),
123
+ type: opts.type,
124
+ toRef: opts.toRef,
125
+ scanRoot: opts.stashDir,
126
+ oldPathResolved: path.resolve(opts.oldPath),
127
+ twinOldPathResolved: opts.twinOldPath ? path.resolve(opts.twinOldPath) : null,
128
+ };
129
+ }
130
+ /**
131
+ * Replace every occurrence of the moved ref, returning the count replaced.
132
+ *
133
+ * Two passes:
134
+ * 1. the deterministic patterns (canonical / `.md`-suffixed / `local//`);
135
+ * 2. the resolver scan — any remaining ref-shaped token of the moved type
136
+ * that resolves (via `resolveRefPathInStash`, lint's shared resolver)
137
+ * to the moved file's old path is an alias spelling of the same asset
138
+ * and is rewritten to the new canonical ref too. Because this pass
139
+ * rewrites EVERY token that resolves to the old path, nothing
140
+ * ref-shaped can still point at the moved file afterwards.
141
+ *
142
+ * Runs at PLANNING time, before the rename — the old file must still be on
143
+ * disk for the resolver probes.
144
+ */
145
+ function rewriteRefs(content, ctx) {
146
+ let count = 0;
147
+ let next = content;
148
+ for (const pattern of ctx.patterns) {
149
+ next = next.replace(pattern, (_match, prefix, derivedTail) => {
150
+ count += 1;
151
+ return `${prefix}${ctx.toRef}${derivedTail ?? ""}`;
152
+ });
153
+ }
154
+ /**
155
+ * Probe one ref-shaped token through lint's shared resolver:
156
+ * - `{ rewriteTo }` — it resolves to the moved file (or its twin) and
157
+ * must be rewritten to the new canonical ref;
158
+ * - `"other"` — it names a different asset (or IS the new canonical ref,
159
+ * just written by pass 1, which may itself resolve to the old path
160
+ * through a fallback — e.g. moving knowledge:guides/x to the knowledge
161
+ * root while guides/x.md still exists at planning time; never rewrite
162
+ * it to itself) and must be left alone;
163
+ * - `"unresolved"` — it resolves to nothing (the punctuation-retry case).
164
+ */
165
+ const probe = (token) => {
166
+ const bare = token.startsWith("local//") ? token.slice("local//".length) : token;
167
+ if (bare === ctx.toRef || bare === `${ctx.toRef}.derived`)
168
+ return "other";
169
+ const name = bare.slice(ctx.type.length + 1);
170
+ if (!name)
171
+ return "unresolved";
172
+ const relPath = refToRelPath(ctx.type, name);
173
+ if (!relPath)
174
+ return "unresolved";
175
+ const resolved = resolveRefPathInStash(relPath, ctx.type, name, ctx.scanRoot);
176
+ if (!resolved)
177
+ return "unresolved";
178
+ const resolvedAbs = path.resolve(resolved);
179
+ if (resolvedAbs === ctx.oldPathResolved)
180
+ return { rewriteTo: ctx.toRef };
181
+ if (ctx.twinOldPathResolved && resolvedAbs === ctx.twinOldPathResolved) {
182
+ return { rewriteTo: `${ctx.toRef}.derived` };
183
+ }
184
+ return "other";
185
+ };
186
+ next = next.replace(ctx.aliasScan, (match, prefix, token) => {
187
+ const full = probe(token);
188
+ if (full !== "other" && full !== "unresolved") {
189
+ count += 1;
190
+ return `${prefix}${full.rewriteTo}`;
191
+ }
192
+ if (full === "other")
193
+ return match;
194
+ // The slug charset admits sentence punctuation ('.', ';', ':', '!', '?'),
195
+ // so a prose citation like "See memory:old." parses as the token "old." —
196
+ // which resolves to nothing. Retry with the trailing punctuation run
197
+ // stripped, but ONLY after the full token failed to resolve: a genuinely
198
+ // dotted name (memory:v1.2-notes) or a `.md`-suffixed alias that resolves
199
+ // won the probe above and is never mangled. The punctuation is preserved
200
+ // outside the rewritten ref.
201
+ const punctuation = /[.,;:!?)]+$/.exec(token)?.[0] ?? "";
202
+ if (!punctuation || punctuation.length === token.length)
203
+ return match;
204
+ const trimmed = probe(token.slice(0, token.length - punctuation.length));
205
+ if (trimmed !== "other" && trimmed !== "unresolved") {
206
+ count += 1;
207
+ return `${prefix}${trimmed.rewriteTo}${punctuation}`;
208
+ }
209
+ return match;
210
+ });
211
+ return { content: next, count };
212
+ }
213
+ // ── File walking ──────────────────────────────────────────────────────────────
214
+ /**
215
+ * Every ref-carrying file under `root`, recursively: all `.md` files, plus
216
+ * `.yml`/`.yaml` files under the `tasks/` and `workflows/` type dirs — task
217
+ * YAML legitimately carries refs (`workflow: workflow:…`, `prompt:
218
+ * agent:/memory:…`, see src/tasks/parser.ts) and workflow YAML *programs*
219
+ * carry refs in their step/instructions text, and lint's missing-ref body
220
+ * scan covers both, so a rename must rewrite them like any other citer or
221
+ * the scheduled task / workflow step dangles. (Workflows are CITERS only:
222
+ * `workflow:` refs still cannot be MOVED — see {@link MV_SUPPORTED_TYPES}.)
223
+ * Skips dot-directories (index state, `.cache/` mirrors) and `registry/`
224
+ * caches — the same read-only carve-outs `akm lint --fix` honours
225
+ * (lint/index.ts).
226
+ */
227
+ function collectCiterFiles(root) {
228
+ const tasksRoot = path.join(root, TYPE_DIRS.task ?? "tasks");
229
+ const workflowsRoot = path.join(root, TYPE_DIRS.workflow ?? "workflows");
230
+ const results = [];
231
+ const walk = (dir) => {
232
+ let entries;
233
+ try {
234
+ entries = fs.readdirSync(dir, { withFileTypes: true });
235
+ }
236
+ catch {
237
+ return;
238
+ }
239
+ for (const entry of entries) {
240
+ if (entry.name.startsWith("."))
241
+ continue;
242
+ const full = path.join(dir, entry.name);
243
+ if (entry.isDirectory()) {
244
+ if (entry.name === "registry")
245
+ continue;
246
+ walk(full);
247
+ }
248
+ else if (entry.isFile()) {
249
+ if (entry.name.endsWith(".md")) {
250
+ results.push(full);
251
+ }
252
+ else if ((entry.name.endsWith(".yml") || entry.name.endsWith(".yaml")) &&
253
+ (isWithin(full, tasksRoot) || isWithin(full, workflowsRoot))) {
254
+ results.push(full);
255
+ }
256
+ }
257
+ }
258
+ };
259
+ walk(root);
260
+ return results;
261
+ }
262
+ // ── Source resolution ─────────────────────────────────────────────────────────
263
+ /**
264
+ * Return the ON-DISK casing of `relPath` under `root` as a posix-separated
265
+ * relative path, or `null` when a segment cannot be found (file deleted
266
+ * mid-flight, unreadable directory — callers treat null as "unverifiable"
267
+ * and keep the `existsSync` verdict).
268
+ *
269
+ * The casing guard for {@link resolveMoveSourcePath}: on a case-INSENSITIVE
270
+ * filesystem (macOS/Windows defaults) `existsSync` matches a wrong-case
271
+ * spelling and `resolveRefPathInStash` returns the user-cased join verbatim,
272
+ * so a byte-comparison of the resolved path against the ref-derived path
273
+ * compares the string against itself. This helper reads each path segment's
274
+ * true name from its parent's directory listing instead — deliberately NOT
275
+ * `fs.realpathSync.native`, which also resolves symlinks and would
276
+ * false-mismatch a stash root reached through one (e.g. /tmp on macOS).
277
+ * Matching is byte-first, then Unicode-lowercase — an approximation of the
278
+ * filesystem's own case folding that can only miss toward `null`
279
+ * (unverifiable), never toward a wrong entry.
280
+ */
281
+ export function deriveOnDiskCasedRelPath(root, relPath) {
282
+ const segments = toPosix(relPath).split("/").filter(Boolean);
283
+ const cased = [];
284
+ let dir = root;
285
+ for (const segment of segments) {
286
+ let entries;
287
+ try {
288
+ entries = fs.readdirSync(dir);
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ const onDisk = entries.includes(segment)
294
+ ? segment
295
+ : entries.find((entry) => entry.toLowerCase() === segment.toLowerCase());
296
+ if (onDisk === undefined)
297
+ return null;
298
+ cased.push(onDisk);
299
+ dir = path.join(dir, onDisk);
300
+ }
301
+ return cased.join("/");
302
+ }
303
+ /**
304
+ * Resolve the on-disk file for the ref being moved, within the primary
305
+ * writable stash ONLY. Reuses lint's shared resolver (`resolveRefPathInStash`,
306
+ * base-linter.ts — do not fork a second resolver), then requires the hit to
307
+ * be CANONICAL: the resolved file must be exactly `<stashDir>/<relPath>`, the
308
+ * path the ref's spelling maps to.
309
+ *
310
+ * Lint's resolver accepts fallback spellings on purpose (a knowledge-subdir
311
+ * alias like `knowledge:g` for `knowledge/guides/g.md`, a refName encoding a
312
+ * full stash-relative path, a base memory ref satisfied by its `.derived.md`
313
+ * twin, a `SKILL.md` directory primary) — fine for existence checks, fatal
314
+ * for a move: the citer rewrite targets the typed spelling (canonical citers
315
+ * would dangle), the index re-key derives its old entry_key from the typed
316
+ * spelling (the real row would be stranded as a ghost and a duplicate row
317
+ * minted — the exact utility-history reset `mv` exists to prevent), and the
318
+ * direct-path fallback would even RELOCATE the file out of its real home into
319
+ * the type root. So:
320
+ * - `null` — the ref does not resolve at all (also for a base memory ref
321
+ * whose only on-disk presence is the `.derived.md` twin: the base file is
322
+ * what `mv` renames — the twin is carried along, never moved alone —
323
+ * and for a `SKILL.md` directory primary, out of v1 scope);
324
+ * - throws `UsageError` (exit 2) — the ref resolves ONLY via a fallback
325
+ * spelling; the message names the canonical ref when one is derivable.
326
+ */
327
+ function resolveMoveSourcePath(stashDir, relPath, refType, refName) {
328
+ const resolved = resolveRefPathInStash(relPath, refType, refName, stashDir);
329
+ if (!resolved)
330
+ return null;
331
+ if (resolved.endsWith(".derived.md") && !refName.endsWith(".derived"))
332
+ return null;
333
+ if (path.basename(resolved) === "SKILL.md" && !relPath.endsWith(`${path.sep}SKILL.md`))
334
+ return null;
335
+ // The byte-equal comparison below cannot catch a CASE alias: on a
336
+ // case-insensitive filesystem the resolver's `existsSync` matches
337
+ // `memory:Foo` against memories/foo.md and returns the USER-cased join, so
338
+ // the comparison checks the string against itself. Every downstream key is
339
+ // case-sensitive regardless of the filesystem (the citer rewrite matches
340
+ // bytes, the index entry_key is BINARY-collated, state.db asset_ref
341
+ // likewise), so a wrong-case source must be rejected like any other
342
+ // fallback spelling: verify the ON-DISK casing and, on mismatch, fall
343
+ // through to the rejection below with the true-cased path so the error
344
+ // names the canonical ref.
345
+ let onDiskResolved = resolved;
346
+ if (path.resolve(resolved) === path.resolve(stashDir, relPath)) {
347
+ const onDiskRelPath = deriveOnDiskCasedRelPath(stashDir, relPath);
348
+ if (onDiskRelPath === null || onDiskRelPath === toPosix(relPath))
349
+ return resolved;
350
+ onDiskResolved = path.join(stashDir, onDiskRelPath);
351
+ }
352
+ // Fallback hit — reject, steering to the canonical spelling when it exists.
353
+ const typedRef = refToString({ type: refType, name: refName });
354
+ const canonicalName = deriveCanonicalAssetNameFromStashRoot(refType, stashDir, onDiskResolved);
355
+ const canonicalRelPath = canonicalName ? refToRelPath(refType, canonicalName) : null;
356
+ if (canonicalName &&
357
+ canonicalName !== refName &&
358
+ canonicalRelPath &&
359
+ path.resolve(stashDir, canonicalRelPath) === path.resolve(onDiskResolved)) {
360
+ const canonicalRef = refToString({ type: refType, name: canonicalName });
361
+ throw new UsageError(`"${typedRef}" resolves only through a fallback spelling — the asset's canonical ref is ${canonicalRef}. ` +
362
+ "akm mv needs the canonical spelling so the citer rewrite and the index re-key target the same ref — nothing moved.", "INVALID_FLAG_VALUE", `Re-run with the canonical ref: akm mv ${canonicalRef} <new-name>.`);
363
+ }
364
+ throw new UsageError(`"${typedRef}" resolves to ${toPosix(path.relative(stashDir, onDiskResolved))}, outside the ${TYPE_DIRS[refType]}/ ` +
365
+ "type root — akm mv renames within a type directory only; nothing moved.", "INVALID_FLAG_VALUE");
366
+ }
367
+ // ── Index re-key ──────────────────────────────────────────────────────────────
368
+ /**
369
+ * Re-key the moved row(s) in the local index, preserving row ids (and with
370
+ * them the utility/embedding/salience history). FAIL-OPEN like every
371
+ * write-path index touch: an absent index.db is skipped silently (the next
372
+ * full `akm index` picks the renamed file up fresh), and any error reduces
373
+ * to a verbose warning — the rename itself has already succeeded.
374
+ *
375
+ * The returned flag is the `utilityPreserved` claim in the command's output,
376
+ * so it must be honest:
377
+ * - true — no index exists, the file was never indexed (nothing to
378
+ * preserve; the next `akm index` picks it up fresh), or the row(s) were
379
+ * re-keyed in place;
380
+ * - false — an existing index could not be re-keyed (open/SQL error), or a
381
+ * row for the moved file exists under some OTHER entry_key than the one
382
+ * the canonical spelling derives (e.g. a differently-normalized stash
383
+ * path at index time): that history is now stranded on a ghost row and
384
+ * will NOT survive the next index run.
385
+ *
386
+ * When `preserved` is false, `warning` carries the reason for the command's
387
+ * JSON report — a re-key failure must be user-visible, not verbose-only.
388
+ */
389
+ function rekeyIndexForMove(opts) {
390
+ const dbPath = getDbPath();
391
+ try {
392
+ if (!fs.existsSync(dbPath))
393
+ return { preserved: true, warning: null };
394
+ let preserved = true;
395
+ const db = openExistingDatabase(dbPath);
396
+ try {
397
+ db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
398
+ // A null re-key means "no row under the expected old key". That is fine
399
+ // when the file was simply never indexed, but a lie if a row for the
400
+ // file DOES exist under another key — then history is stranded.
401
+ const strandedRow = (movedFrom) => db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(movedFrom) != null;
402
+ const oldKey = `${opts.stashDir}:${opts.type}:${opts.oldName}`;
403
+ const newKey = `${opts.stashDir}:${opts.type}:${opts.newName}`;
404
+ const rekeyed = rekeyEntryInPlace(db, {
405
+ oldEntryKey: oldKey,
406
+ newEntryKey: newKey,
407
+ newName: opts.newName,
408
+ newFilePath: opts.newPath,
409
+ oldRef: opts.fromRef,
410
+ newRef: opts.toRef,
411
+ });
412
+ if (rekeyed === null && strandedRow(opts.oldPath))
413
+ preserved = false;
414
+ let twinRekeyed = null;
415
+ if (opts.twinNewPath) {
416
+ // The twin coupling (db.ts getBaseBeliefStatesForDerivedTwins) is
417
+ // `twin entry_key === base entry_key + ".derived"` — preserved here.
418
+ twinRekeyed = rekeyEntryInPlace(db, {
419
+ oldEntryKey: `${oldKey}.derived`,
420
+ newEntryKey: `${newKey}.derived`,
421
+ newName: `${opts.newName}.derived`,
422
+ newFilePath: opts.twinNewPath,
423
+ oldRef: `${opts.fromRef}.derived`,
424
+ newRef: `${opts.toRef}.derived`,
425
+ newDerivedFrom: opts.toRef,
426
+ });
427
+ if (twinRekeyed === null && opts.twinOldPath && strandedRow(opts.twinOldPath))
428
+ preserved = false;
429
+ }
430
+ if (rekeyed !== null || twinRekeyed !== null) {
431
+ rebuildFts(db, { incremental: true });
432
+ }
433
+ }
434
+ finally {
435
+ closeDatabase(db);
436
+ }
437
+ if (!preserved) {
438
+ const warning = "index re-key skipped: the index holds a row for the moved file under an unexpected key — its utility " +
439
+ "history was not re-keyed and resets on the next `akm index`.";
440
+ warnVerbose(`akm mv: ${warning}`);
441
+ return { preserved: false, warning };
442
+ }
443
+ return { preserved: true, warning: null };
444
+ }
445
+ catch (error) {
446
+ const message = error instanceof Error ? error.message : String(error);
447
+ const warning = `index re-key failed (${message}) — the rename itself succeeded and the index heals on the next ` +
448
+ "`akm index`, but the asset's utility history was NOT re-keyed and resets on that run.";
449
+ warnVerbose(`akm mv: ${warning}`);
450
+ return { preserved: false, warning };
451
+ }
452
+ }
453
+ /**
454
+ * Re-key the state.db `asset_salience` / `asset_outcome` rows after a rename.
455
+ *
456
+ * Both tables are keyed by `asset_ref` TEXT (the bare `makeAssetRef`
457
+ * `type:name` form — NOT entry_id; see core/state/migrations.ts 009/010), so
458
+ * the salience boost `loadSalienceRankScores` applies at search time and the
459
+ * outcome-loop history would otherwise strand on the old ref until the next
460
+ * improve run re-mints a type-weight stub row — losing a distill-written
461
+ * content-derived `encoding_salience` for good.
462
+ *
463
+ * Collision policy (conservative): a row already sitting at the NEW ref can
464
+ * only be an orphan of a previously deleted asset — the caller has verified
465
+ * no file exists at the target — so the LIVE asset's history wins: the
466
+ * orphan row is deleted and the moved asset's row re-keyed onto the ref.
467
+ *
468
+ * FAIL-OPEN like the index re-key: no state.db means the improve loop never
469
+ * ran (nothing to re-key); the file is never created here, migrations are
470
+ * never run (a missing table on an old state.db is simply skipped), and any
471
+ * error reduces to a warning in the report — the rename has already
472
+ * succeeded. Returns the warning for the JSON report, or null.
473
+ */
474
+ function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
475
+ const statePath = getStateDbPath();
476
+ try {
477
+ if (!fs.existsSync(statePath))
478
+ return null;
479
+ const pairs = [[fromRef, toRef]];
480
+ if (includeTwin)
481
+ pairs.push([`${fromRef}.derived`, `${toRef}.derived`]);
482
+ const db = openDatabase(statePath);
483
+ const tableFailures = [];
484
+ try {
485
+ db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
486
+ for (const table of ["asset_salience", "asset_outcome"]) {
487
+ try {
488
+ db.transaction(() => {
489
+ for (const [oldRef, newRef] of pairs) {
490
+ const moved = db.prepare(`SELECT asset_ref FROM ${table} WHERE asset_ref = ?`).get(oldRef);
491
+ if (!moved)
492
+ continue;
493
+ db.prepare(`DELETE FROM ${table} WHERE asset_ref = ?`).run(newRef);
494
+ db.prepare(`UPDATE ${table} SET asset_ref = ? WHERE asset_ref = ?`).run(newRef, oldRef);
495
+ }
496
+ })();
497
+ }
498
+ catch (error) {
499
+ const message = error instanceof Error ? error.message : String(error);
500
+ // The ONLY swallowable failure: the table is missing on an older
501
+ // state.db (migrations run in the improve loop, never here) —
502
+ // nothing of this kind to re-key. Anything else (lock timeout,
503
+ // incompatible schema) strands history and must reach the report.
504
+ if (/no such table/i.test(message))
505
+ continue;
506
+ tableFailures.push(`${table}: ${message}`);
507
+ }
508
+ }
509
+ }
510
+ finally {
511
+ db.close();
512
+ }
513
+ if (tableFailures.length > 0) {
514
+ const warning = `state.db salience re-key failed (${tableFailures.join("; ")}) — the rename itself succeeded, but the ` +
515
+ "asset's salience/outcome history stays keyed to the old ref until the next improve run re-mints it.";
516
+ warnVerbose(`akm mv: ${warning}`);
517
+ return warning;
518
+ }
519
+ return null;
520
+ }
521
+ catch (error) {
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ const warning = `state.db salience re-key failed (${message}) — the rename itself succeeded, but the asset's salience/` +
524
+ "outcome history stays keyed to the old ref until the next improve run re-mints it.";
525
+ warnVerbose(`akm mv: ${warning}`);
526
+ return warning;
527
+ }
528
+ }
529
+ // ── Command ───────────────────────────────────────────────────────────────────
530
+ export const mvCommand = defineJsonCommand({
531
+ meta: {
532
+ name: "mv",
533
+ description: "Rename an asset within its type directory (Experimental). Moves the file (a memory's .derived.md twin " +
534
+ "moves together), rewrites inbound refs across the writable stash in the same pass — body prose, " +
535
+ "frontmatter ref lists (xrefs/refs/supersededBy/...), fenced code examples, task .yml files under tasks/, " +
536
+ "workflow .yaml/.yml programs under workflows/, and alias spellings of the same asset (.md-suffixed, " +
537
+ "local//-prefixed, and resolver-fallback forms are rewritten to the new canonical ref) — and re-keys the " +
538
+ "search-index row in place (including its usage-event history) plus the state.db salience/outcome rows, " +
539
+ "so the asset's accumulated usage-ranking history survives the rename. Read-only sources are scanned but " +
540
+ "never written; their citing files are reported in `readOnlyCiters` as manual follow-ups. Operates on the " +
541
+ "primary writable stash only. The source ref (and the target name) may carry the .md-suffixed alias " +
542
+ "spelling — both are canonicalized — but resolver-fallback source spellings are rejected, naming the " +
543
+ "canonical ref. Wiki refs are not supported (use `akm wiki lint` after a manual wiki rename); workflow " +
544
+ "refs cannot be MOVED in v1 (workflows may be .yaml programs — rename the file manually and verify with " +
545
+ "`akm lint`), though workflow files ARE rewritten as citers.",
546
+ },
547
+ args: {
548
+ ref: {
549
+ type: "positional",
550
+ description: "Current asset ref (required), e.g. memory:projectA/old-note",
551
+ // Optional in citty so run() is invoked even when omitted; re-validated
552
+ // below to surface a structured UsageError (exit 2) instead of citty's
553
+ // unstructured missing-argument failure. The "(required)" note in the
554
+ // description keeps the rendered help honest about that contract.
555
+ required: false,
556
+ },
557
+ newName: {
558
+ type: "positional",
559
+ description: "New name (required; subdirectories allowed, e.g. projectA/new-note), or a same-type ref like memory:new-note",
560
+ required: false,
561
+ },
562
+ },
563
+ async run({ args }) {
564
+ const refArg = typeof args.ref === "string" ? args.ref.trim() : "";
565
+ const targetArg = typeof args.newName === "string" ? args.newName.trim() : "";
566
+ if (!refArg || !targetArg) {
567
+ throw new UsageError("Usage: akm mv <ref> <new-name>.", "MISSING_REQUIRED_ARGUMENT", "Pass the asset's current ref and its new name, e.g. `akm mv memory:projectA/old-note projectA/new-note`.");
568
+ }
569
+ // ── Validation (everything before any write; a failure moves nothing) ──
570
+ const source = parseAssetRef(refArg);
571
+ if (source.origin && source.origin !== "local") {
572
+ throw new UsageError(`akm mv operates on the primary writable stash only — the origin prefix "${source.origin}//" is not supported.`, "INVALID_FLAG_VALUE");
573
+ }
574
+ if (source.type === "wiki") {
575
+ throw new UsageError("akm mv does not support wiki refs — wiki pages have their own xref + lint system. " +
576
+ "Rename the page manually, fix citations in the same pass, and verify with `akm wiki lint <name>`.", "INVALID_FLAG_VALUE");
577
+ }
578
+ if (source.type === "workflow") {
579
+ throw new UsageError("akm mv does not support workflow refs in v1 — workflows may live as .yaml/.yml programs, which the " +
580
+ "flat-markdown rename path would misresolve or rename to .md. Rename the file manually under " +
581
+ "workflows/ (keeping its extension), fix inbound refs in the same pass, and verify with `akm lint`.", "INVALID_FLAG_VALUE");
582
+ }
583
+ if (!MV_SUPPORTED_TYPES.includes(source.type)) {
584
+ throw new UsageError(`akm mv supports flat-markdown asset types (${MV_SUPPORTED_TYPES.join(", ")}); "${source.type}:" refs cannot be moved.`, "INVALID_FLAG_VALUE");
585
+ }
586
+ // The `.derived` suffix is the distilled-twin marker: a twin's entry_key
587
+ // must stay exactly `<base entry_key>.derived` (db.ts
588
+ // getBaseBeliefStatesForDerivedTwins), so a twin can never move alone and
589
+ // no independent asset may squat on the suffix. The `.md`-suffixed alias
590
+ // spelling of a twin ref names the same file, so it is caught here too.
591
+ if (source.type === "memory" && /\.derived(\.md)?$/.test(source.name)) {
592
+ const baseRef = refToString({ type: "memory", name: source.name.replace(/\.derived(\.md)?$/, "") });
593
+ throw new UsageError(`"${refToString({ type: source.type, name: source.name })}" names a .derived.md distilled twin — a twin ` +
594
+ "cannot be moved on its own without breaking its belief-inheritance coupling to the base memory. " +
595
+ `Rename the base ref instead (akm mv ${baseRef} <new-name>); the twin moves with it.`, "INVALID_FLAG_VALUE");
596
+ }
597
+ // The target may be a bare name ("projectA/new-note") or a ref-shaped
598
+ // spelling. Parsing the bare form through the same ref grammar gives it
599
+ // identical name validation (traversal, null bytes, absolute paths).
600
+ const target = parseAssetRef(targetArg.includes(":") ? targetArg : `${source.type}:${targetArg}`);
601
+ if (target.origin) {
602
+ throw new UsageError(`The target must be a name within the ${source.type} type — origin prefixes are not supported.`, "INVALID_FLAG_VALUE");
603
+ }
604
+ if (target.type !== source.type) {
605
+ throw new UsageError(`Cross-type move is not supported: "${refToString({ type: source.type, name: source.name })}" is a ` +
606
+ `${source.type}: asset but the target names the ${target.type}: type. akm mv renames within one asset type.`, "INVALID_FLAG_VALUE");
607
+ }
608
+ // Accept the `.md`-suffixed alias spelling of the TARGET, but operate on
609
+ // the canonical extensionless name: every MV_SUPPORTED_TYPES layout is
610
+ // the markdownSpec family, whose `toAssetPath` writes `<name>.md` either
611
+ // way — so `bar.md` names the same file as `bar`, while a `bar.md`-keyed
612
+ // toRef/entry_key would rewrite citers to a non-canonical ref and strand
613
+ // the re-keyed history behind a row the write-path index pass (which
614
+ // derives the canonical name `bar` from the file) immediately duplicates.
615
+ const newName = target.name.endsWith(".md") ? target.name.slice(0, -".md".length) : target.name;
616
+ if (!newName) {
617
+ throw new UsageError(`Target "${targetArg}" names no asset once the .md extension is stripped — nothing moved.`, "INVALID_FLAG_VALUE");
618
+ }
619
+ // Reject empty path segments: `path.posix.normalize` (parseAssetRef's
620
+ // name normalization) PRESERVES a trailing slash — "bar/" (and "bar\",
621
+ // normalized to it) sails through the traversal checks, and the file
622
+ // would land at e.g. memories/bar/.md: a dot-prefixed file the index
623
+ // walker skips, unreachable by `akm show`, with every citer rewritten to
624
+ // the phantom ref "memory:bar/". Interior doubles ("a//b") are collapsed
625
+ // by the normalization, so a trailing empty segment is the only shape
626
+ // that reaches this check — but reject ANY empty segment regardless.
627
+ if (newName.split("/").some((segment) => segment.length === 0)) {
628
+ throw new UsageError(`Target "${targetArg}" contains an empty path segment (trailing "/" or "\\") — the file would be written ` +
629
+ "as a hidden dotfile the index cannot see. Pass a name, e.g. `akm mv <ref> projectA/new-note` — nothing moved.", "INVALID_FLAG_VALUE");
630
+ }
631
+ if (source.type === "memory" && newName.endsWith(".derived")) {
632
+ throw new UsageError(`The target name "${newName}" ends with the reserved .derived suffix (the distilled-twin marker) — a base ` +
633
+ "memory renamed onto it would masquerade as a twin of a memory that does not exist. Pick a name without " +
634
+ "the suffix; a real twin always moves together with its base.", "INVALID_FLAG_VALUE");
635
+ }
636
+ const toRef = refToString({ type: source.type, name: newName });
637
+ const stashDir = resolveStashDir();
638
+ const typeDir = TYPE_DIRS[source.type];
639
+ const typeRoot = path.join(stashDir, typeDir);
640
+ const oldRelPath = refToRelPath(source.type, source.name);
641
+ const newRelPath = refToRelPath(source.type, newName);
642
+ if (!oldRelPath || !newRelPath) {
643
+ // Unreachable for MV_SUPPORTED_TYPES; guards a future registry change.
644
+ throw new UsageError(`"${source.type}:" refs are not path-resolvable and cannot be moved.`, "INVALID_FLAG_VALUE");
645
+ }
646
+ const oldPath = resolveMoveSourcePath(stashDir, oldRelPath, source.type, source.name);
647
+ if (!oldPath) {
648
+ throw new UsageError(`Cannot resolve ${refToString({ type: source.type, name: source.name })} in the writable stash at ` +
649
+ `${stashDir} — nothing moved.`, "MISSING_REQUIRED_ARGUMENT", "akm mv renames assets in the primary writable stash only. Check the ref with `akm show <ref>` or `akm search`.");
650
+ }
651
+ // The accepted spelling may be the `.md`-suffixed alias of the same file
652
+ // (markdownSpec.toAssetPath maps `foo` and `foo.md` to memories/foo.md).
653
+ // Everything keyed off the source — the citer rewrite patterns, the index
654
+ // entry_key re-key, the state.db asset_ref re-key, the report — must use
655
+ // the CANONICAL extensionless name derived from the resolved path, or the
656
+ // real rows (keyed by the canonical spelling) are silently missed.
657
+ const sourceName = deriveCanonicalAssetNameFromStashRoot(source.type, stashDir, oldPath) ?? source.name;
658
+ const fromRef = refToString({ type: source.type, name: sourceName });
659
+ const newPath = path.join(stashDir, newRelPath);
660
+ // Defense-in-depth: parseAssetRef already rejects `../` traversal, but the
661
+ // computed target must land inside the type root regardless.
662
+ if (!isWithin(newPath, typeRoot)) {
663
+ throw new UsageError(`Target "${targetArg}" escapes the ${typeDir}/ type root — nothing moved.`, "PATH_ESCAPE_VIOLATION");
664
+ }
665
+ if (path.resolve(newPath) === path.resolve(oldPath)) {
666
+ throw new UsageError(`Source and target resolve to the same file (${fromRef}) — nothing to move.`);
667
+ }
668
+ if (fs.existsSync(newPath)) {
669
+ throw new UsageError(`Target ${toRef} already exists at ${toPosix(path.relative(stashDir, newPath))} — nothing moved.`, "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or move the existing asset out of the way first.");
670
+ }
671
+ // Memory `.derived.md` twin: moves together with its base (the entry_key
672
+ // suffix coupling the belief-state inheritance relies on). The TARGET
673
+ // twin-collision check runs whenever the target could carry a twin —
674
+ // NOT only when the source has one: renaming a twin-less memory onto a
675
+ // name whose orphaned `<name>.derived.md` lingers (consolidate/dedup
676
+ // delete the base file without twin cleanup) would silently adopt the
677
+ // stranger file as the renamed memory's distillation.
678
+ const isBaseMemory = source.type === "memory" && !sourceName.endsWith(".derived");
679
+ const twinOldPath = isBaseMemory ? oldPath.replace(/\.md$/, ".derived.md") : null;
680
+ const hasTwin = twinOldPath !== null && fs.existsSync(twinOldPath);
681
+ const targetTwinPath = isBaseMemory ? newPath.replace(/\.md$/, ".derived.md") : null;
682
+ if (targetTwinPath && fs.existsSync(targetTwinPath)) {
683
+ throw new UsageError(`Target twin ${toRef}.derived already exists at ${toPosix(path.relative(stashDir, targetTwinPath))} — ` +
684
+ "renaming onto it would adopt that orphaned distilled twin as this memory's own. Nothing moved.", "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or delete the orphaned .derived.md file first if it belongs to a removed memory.");
685
+ }
686
+ const twinNewPath = hasTwin ? targetTwinPath : null;
687
+ // ── Plan the inbound-ref rewrite (no writes yet) ───────────────────────
688
+ const rewriteCtx = buildRewriteContext({
689
+ type: source.type,
690
+ fromRef,
691
+ toRef,
692
+ isBaseMemory,
693
+ stashDir,
694
+ oldPath,
695
+ twinOldPath: hasTwin ? twinOldPath : null,
696
+ });
697
+ const plans = [];
698
+ for (const absPath of collectCiterFiles(stashDir)) {
699
+ let raw;
700
+ try {
701
+ raw = fs.readFileSync(absPath, "utf8");
702
+ }
703
+ catch {
704
+ continue;
705
+ }
706
+ const { content, count } = rewriteRefs(raw, rewriteCtx);
707
+ if (count > 0) {
708
+ plans.push({ absPath, relPath: toPosix(path.relative(stashDir, absPath)), count, content });
709
+ }
710
+ }
711
+ // Read-only sources: scanned, never written — manual follow-ups.
712
+ const readOnlyCiters = [];
713
+ let sources = [];
714
+ try {
715
+ sources = resolveSourceEntries(stashDir);
716
+ }
717
+ catch (error) {
718
+ warnVerbose("akm mv: could not enumerate configured sources for the read-only citer scan:", error instanceof Error ? error.message : String(error));
719
+ }
720
+ for (const src of sources) {
721
+ if (path.resolve(src.path) === path.resolve(stashDir))
722
+ continue;
723
+ for (const absPath of collectCiterFiles(src.path)) {
724
+ let raw;
725
+ try {
726
+ raw = fs.readFileSync(absPath, "utf8");
727
+ }
728
+ catch {
729
+ continue;
730
+ }
731
+ // Same detection as the writable pass (canonical + alias spellings,
732
+ // alias tokens resolved against the WRITABLE stash where the moved
733
+ // file lives) — count-only, never written.
734
+ const { count } = rewriteRefs(raw, rewriteCtx);
735
+ if (count > 0)
736
+ readOnlyCiters.push({ file: absPath, count });
737
+ }
738
+ }
739
+ // ── Apply citer edits, then rename last (see module docstring) ────────
740
+ // The target's parent directory is created FIRST: if it cannot be (a
741
+ // segment of the target's subdirectory path exists as a FILE, or the
742
+ // parent is unwritable), the command must abort before any citer has
743
+ // been edited — otherwise citers would already point at a ref whose
744
+ // file never arrives.
745
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
746
+ for (const plan of plans) {
747
+ fs.writeFileSync(plan.absPath, plan.content, "utf8");
748
+ }
749
+ fs.renameSync(oldPath, newPath);
750
+ if (twinOldPath && twinNewPath) {
751
+ fs.renameSync(twinOldPath, twinNewPath);
752
+ }
753
+ // ── Index + state.db: re-key in place, then reindex touched files ─────
754
+ const { preserved: utilityPreserved, warning: indexWarning } = rekeyIndexForMove({
755
+ stashDir,
756
+ type: source.type,
757
+ oldName: sourceName,
758
+ newName,
759
+ oldPath,
760
+ newPath,
761
+ fromRef,
762
+ toRef,
763
+ twinOldPath: hasTwin ? twinOldPath : null,
764
+ twinNewPath,
765
+ });
766
+ // Salience/outcome history lives in state.db keyed by asset_ref TEXT —
767
+ // re-keyed here so the salience boost genuinely "survives the rename".
768
+ const stateWarning = rekeyStateDbForMove(fromRef, toRef, hasTwin);
769
+ const warnings = [indexWarning, stateWarning].filter((w) => w !== null);
770
+ // Rewritten citers (and the moved file itself) go through the standard
771
+ // write-path reindex so their FTS hints reflect the new ref immediately.
772
+ // Fail-open; an absent/empty index is skipped inside the helper.
773
+ const touched = new Set([newPath]);
774
+ if (twinNewPath)
775
+ touched.add(twinNewPath);
776
+ for (const plan of plans) {
777
+ // A self-citing moved file was edited at its OLD path but now lives at
778
+ // the new one; report the file that exists.
779
+ if (plan.absPath === oldPath)
780
+ continue;
781
+ if (twinOldPath && plan.absPath === twinOldPath)
782
+ continue;
783
+ touched.add(plan.absPath);
784
+ }
785
+ await indexWrittenAssets(stashDir, [...touched]);
786
+ appendEvent({
787
+ eventType: "mv",
788
+ ref: toRef,
789
+ metadata: {
790
+ from: fromRef,
791
+ to: toRef,
792
+ rewroteFiles: plans.length,
793
+ readOnlyCiters: readOnlyCiters.length,
794
+ twinMoved: hasTwin,
795
+ },
796
+ });
797
+ output("mv", {
798
+ ok: true,
799
+ from: fromRef,
800
+ to: toRef,
801
+ rewrote: plans.map((plan) => ({ file: plan.relPath, count: plan.count })),
802
+ readOnlyCiters,
803
+ utilityPreserved,
804
+ // Additive: present only when a re-key could not be completed, so the
805
+ // report (not just --verbose stderr) says WHY history may reset.
806
+ ...(warnings.length > 0 ? { warnings } : {}),
807
+ });
808
+ },
809
+ });