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.
- package/CHANGELOG.md +223 -9
- package/SECURITY.md +23 -24
- package/dist/assets/help/help-improve.md +10 -10
- package/dist/assets/hints/cli-hints-full.md +44 -10
- package/dist/assets/hints/cli-hints-short.md +6 -2
- package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
- package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
- package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
- package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
- package/dist/assets/stash-skeleton/README.md +28 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
- package/dist/assets/tasks/core/backup.yml +1 -0
- package/dist/assets/tasks/core/extract.yml +1 -0
- package/dist/assets/tasks/core/improve.yml +1 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -0
- package/dist/assets/tasks/core/sync.yml +1 -0
- package/dist/assets/tasks/core/version-check.yml +1 -0
- package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
- package/dist/assets/templates/html/health.html +5 -1
- package/dist/cli/config-migrate.js +31 -138
- package/dist/cli/config-validate.js +10 -8
- package/dist/cli.js +48 -14
- package/dist/commands/agent/agent-dispatch.js +17 -16
- package/dist/commands/agent/agent-support.js +0 -24
- package/dist/commands/agent/contribute-cli.js +5 -15
- package/dist/commands/backup-cli.js +54 -0
- package/dist/commands/config-cli.js +45 -159
- package/dist/commands/env/secret.js +8 -5
- package/dist/commands/health/checks.js +130 -83
- package/dist/commands/health/html-report.js +4 -0
- package/dist/commands/health/improve-metrics.js +30 -32
- package/dist/commands/health/llm-usage.js +19 -19
- package/dist/commands/health/md-report.js +4 -0
- package/dist/commands/health/metrics.js +2 -1
- package/dist/commands/health/surfaces.js +5 -4
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/consolidate/chunking.js +2 -2
- package/dist/commands/improve/consolidate.js +28 -25
- package/dist/commands/improve/distill/promote-memory.js +5 -12
- package/dist/commands/improve/distill/quality-gate.js +5 -7
- package/dist/commands/improve/distill.js +16 -5
- package/dist/commands/improve/eligibility.js +22 -12
- package/dist/commands/improve/extract-cli.js +47 -19
- package/dist/commands/improve/extract.js +110 -62
- package/dist/commands/improve/improve-cli.js +38 -16
- package/dist/commands/improve/improve-result-file.js +30 -24
- package/dist/commands/improve/improve-strategies.js +137 -0
- package/dist/commands/improve/improve.js +60 -30
- package/dist/commands/improve/locks.js +66 -45
- package/dist/commands/improve/loop-stages.js +75 -33
- package/dist/commands/improve/memory/memory-belief.js +79 -7
- package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
- package/dist/commands/improve/preparation.js +71 -73
- package/dist/commands/improve/procedural.js +3 -2
- package/dist/commands/improve/recombine.js +2 -1
- package/dist/commands/improve/reflect.js +119 -214
- package/dist/commands/improve/shared.js +11 -5
- package/dist/commands/lint/base-linter.js +152 -42
- package/dist/commands/mv-cli.js +809 -0
- package/dist/commands/proposal/proposal-cli.js +18 -8
- package/dist/commands/proposal/propose.js +64 -69
- package/dist/commands/read/knowledge.js +436 -4
- package/dist/commands/read/remember-cli.js +39 -2
- package/dist/commands/read/search-cli.js +6 -1
- package/dist/commands/registry-cli.js +29 -14
- package/dist/commands/remember.js +2 -0
- package/dist/commands/sources/init.js +13 -14
- package/dist/commands/sources/schema-repair.js +2 -4
- package/dist/commands/sources/source-add.js +62 -73
- package/dist/commands/sources/source-manage.js +50 -46
- package/dist/commands/sources/stash-cli.js +41 -4
- package/dist/commands/tasks/default-tasks.js +12 -12
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +113 -18
- package/dist/commands/wiki-cli.js +9 -10
- package/dist/core/asset/frontmatter.js +12 -2
- package/dist/core/common.js +5 -3
- package/dist/core/config/config-io.js +28 -17
- package/dist/core/config/config-schema.js +358 -66
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config-version.js +29 -0
- package/dist/core/config/config-walker.js +98 -27
- package/dist/core/config/config.js +132 -266
- package/dist/core/config/deep-merge.js +41 -0
- package/dist/core/config/engine-semantics.js +32 -0
- package/dist/core/errors.js +2 -2
- package/dist/core/extra-params.js +61 -0
- package/dist/core/file-lock.js +201 -56
- package/dist/core/improve-result.js +178 -0
- package/dist/core/maintenance-barrier.js +119 -0
- package/dist/core/migration-backup.js +416 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/redaction.js +358 -0
- package/dist/core/state/migrations.js +17 -2
- package/dist/core/state-db.js +44 -1
- package/dist/indexer/db/db.js +116 -1
- package/dist/indexer/graph/graph-extraction.js +28 -16
- package/dist/indexer/index-writer-lock.js +31 -24
- package/dist/indexer/index-written-assets.js +15 -6
- package/dist/indexer/indexer.js +47 -2
- package/dist/indexer/passes/memory-inference.js +10 -6
- package/dist/indexer/passes/metadata.js +250 -0
- package/dist/indexer/search/db-search.js +111 -44
- package/dist/indexer/search/fts-query.js +41 -0
- package/dist/indexer/search/ranking-contributors.js +48 -0
- package/dist/indexer/search/ranking.js +36 -23
- package/dist/indexer/search/search-fields.js +11 -1
- package/dist/integrations/agent/builder-shared.js +7 -0
- package/dist/integrations/agent/builders.js +5 -52
- package/dist/integrations/agent/config.js +3 -143
- package/dist/integrations/agent/detect.js +17 -2
- package/dist/integrations/agent/engine-resolution.js +202 -0
- package/dist/integrations/agent/index.js +1 -2
- package/dist/integrations/agent/model-aliases.js +7 -2
- package/dist/integrations/agent/profiles.js +6 -99
- package/dist/integrations/agent/runner-dispatch.js +76 -13
- package/dist/integrations/agent/runner.js +76 -207
- package/dist/integrations/agent/spawn.js +4 -6
- package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
- package/dist/integrations/harnesses/aider/index.js +0 -1
- package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
- package/dist/integrations/harnesses/amazonq/index.js +0 -1
- package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
- package/dist/integrations/harnesses/claude/index.js +0 -2
- package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
- package/dist/integrations/harnesses/codex/index.js +0 -1
- package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
- package/dist/integrations/harnesses/copilot/index.js +0 -1
- package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
- package/dist/integrations/harnesses/gemini/index.js +0 -1
- package/dist/integrations/harnesses/index.js +1 -24
- package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
- package/dist/integrations/harnesses/opencode/index.js +0 -6
- package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
- package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
- package/dist/integrations/harnesses/openhands/index.js +0 -1
- package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
- package/dist/integrations/harnesses/pi/index.js +0 -1
- package/dist/integrations/harnesses/types.js +1 -32
- package/dist/integrations/lockfile.js +32 -21
- package/dist/llm/client.js +48 -14
- package/dist/llm/feature-gate.js +15 -47
- package/dist/llm/graph-extract.js +1 -1
- package/dist/llm/index-passes.js +8 -42
- package/dist/llm/memory-infer-impl.js +1 -1
- package/dist/llm/usage-persist.js +4 -0
- package/dist/llm/usage-telemetry.js +35 -5
- package/dist/output/shapes/helpers.js +2 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/text/helpers.js +3 -1
- package/dist/schemas/akm-config.json +11013 -8600
- package/dist/schemas/akm-task.json +87 -0
- package/dist/schemas/akm-workflow.json +57 -13
- package/dist/scripts/migrate-storage.js +8591 -509
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
- package/dist/setup/detected-engines.js +142 -0
- package/dist/setup/engine-config.js +89 -0
- package/dist/setup/setup.js +236 -132
- package/dist/setup/steps/connection.js +61 -32
- package/dist/setup/steps/platforms.js +4 -4
- package/dist/setup/steps.js +3 -2
- package/dist/storage/database.js +13 -1
- package/dist/storage/engines/sqlite-migrations.js +1 -0
- package/dist/storage/repositories/improve-runs-repository.js +5 -5
- package/dist/storage/repositories/task-history-repository.js +78 -0
- package/dist/storage/repositories/workflow-runs-repository.js +9 -8
- package/dist/tasks/parser.js +138 -52
- package/dist/tasks/runner.js +71 -75
- package/dist/tasks/schema.js +1 -1
- package/dist/tasks/validator.js +11 -6
- package/dist/wiki/wiki.js +9 -8
- package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
- package/dist/workflows/concurrency-policy.js +15 -0
- package/dist/workflows/db.js +65 -13
- package/dist/workflows/exec/brief.js +18 -24
- package/dist/workflows/exec/frozen-judge.js +47 -0
- package/dist/workflows/exec/native-executor.js +124 -65
- package/dist/workflows/exec/report.js +93 -33
- package/dist/workflows/exec/run-workflow.js +38 -25
- package/dist/workflows/exec/scheduler.js +12 -41
- package/dist/workflows/exec/step-work.js +91 -35
- package/dist/workflows/ir/compile.js +13 -26
- package/dist/workflows/ir/freeze.js +243 -0
- package/dist/workflows/ir/plan-hash.js +40 -5
- package/dist/workflows/ir/schema.js +542 -1
- package/dist/workflows/parser.js +7 -0
- package/dist/workflows/program/parser.js +132 -23
- package/dist/workflows/program/project.js +3 -4
- package/dist/workflows/program/schema.js +2 -2
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/plan-classifier.js +187 -0
- package/dist/workflows/runtime/runs.js +54 -47
- package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
- package/dist/workflows/validator.js +25 -0
- package/docs/data-and-telemetry.md +2 -2
- package/docs/migration/release-notes/0.6.0.md +1 -1
- package/docs/migration/release-notes/0.7.0.md +5 -4
- package/docs/migration/v0.8-to-v0.9.md +401 -0
- package/package.json +4 -2
- package/schemas/akm-config.json +16638 -0
- package/schemas/akm-task.json +87 -0
- package/schemas/akm-workflow.json +372 -0
- package/dist/commands/improve/improve-profiles.js +0 -168
- package/dist/core/config/config-migration.js +0 -602
- package/dist/core/deep-merge.js +0 -38
- package/dist/llm/call-ai.js +0 -62
- package/dist/setup/legacy-config.js +0 -106
- package/docs/README.md +0 -104
- /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
- /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
|
+
});
|