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

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 (88) hide show
  1. package/CHANGELOG.md +34 -8
  2. package/dist/assets/improve-strategies/catchup.json +3 -1
  3. package/dist/assets/improve-strategies/consolidate.json +3 -1
  4. package/dist/assets/improve-strategies/frequent.json +3 -1
  5. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  6. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  8. package/dist/assets/improve-strategies/quick.json +3 -1
  9. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  11. package/dist/assets/improve-strategies/synthesize.json +2 -0
  12. package/dist/assets/tasks/core/backup.yml +2 -2
  13. package/dist/cli/config-migrate.js +554 -26
  14. package/dist/cli.js +3 -1
  15. package/dist/commands/backup-cli.js +6 -4
  16. package/dist/commands/config-cli.js +10 -3
  17. package/dist/commands/feedback-cli.js +24 -7
  18. package/dist/commands/health/checks.js +94 -15
  19. package/dist/commands/health/surfaces.js +2 -1
  20. package/dist/commands/improve/anti-collapse.js +3 -3
  21. package/dist/commands/improve/collapse-detector.js +5 -5
  22. package/dist/commands/improve/consolidate.js +123 -66
  23. package/dist/commands/improve/distill/promote-memory.js +8 -6
  24. package/dist/commands/improve/distill/quality-gate.js +1 -1
  25. package/dist/commands/improve/distill-guards.js +1 -1
  26. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  27. package/dist/commands/improve/distill.js +52 -36
  28. package/dist/commands/improve/eligibility.js +9 -8
  29. package/dist/commands/improve/extract-prompt.js +2 -2
  30. package/dist/commands/improve/extract.js +43 -11
  31. package/dist/commands/improve/improve-auto-accept.js +14 -28
  32. package/dist/commands/improve/improve-cli.js +4 -2
  33. package/dist/commands/improve/improve-strategies.js +2 -1
  34. package/dist/commands/improve/improve.js +49 -8
  35. package/dist/commands/improve/loop-stages.js +11 -6
  36. package/dist/commands/improve/preparation.js +51 -17
  37. package/dist/commands/improve/procedural.js +10 -6
  38. package/dist/commands/improve/recombine.js +31 -7
  39. package/dist/commands/improve/reflect.js +24 -14
  40. package/dist/commands/improve/salience.js +5 -1
  41. package/dist/commands/improve/source-identity.js +43 -0
  42. package/dist/commands/migrate-cli.js +36 -0
  43. package/dist/commands/mv-cli.js +647 -258
  44. package/dist/commands/proposal/drain.js +16 -4
  45. package/dist/commands/proposal/proposal-cli.js +3 -2
  46. package/dist/commands/proposal/proposal.js +16 -55
  47. package/dist/commands/proposal/repository.js +664 -58
  48. package/dist/commands/read/curate.js +4 -2
  49. package/dist/commands/read/knowledge.js +4 -1
  50. package/dist/commands/read/search.js +6 -2
  51. package/dist/commands/read/show.js +8 -7
  52. package/dist/commands/sources/self-update.js +156 -112
  53. package/dist/commands/sources/sources-cli.js +7 -2
  54. package/dist/core/common.js +36 -3
  55. package/dist/core/config/config-io.js +2 -2
  56. package/dist/core/config/config-schema.js +15 -3
  57. package/dist/core/config/config.js +14 -13
  58. package/dist/core/file-lock.js +2 -2
  59. package/dist/core/migration-backup.js +816 -171
  60. package/dist/core/migration-operation.js +44 -0
  61. package/dist/core/state/migrations.js +4 -7
  62. package/dist/core/state-db.js +22 -7
  63. package/dist/core/write-source.js +86 -18
  64. package/dist/indexer/db/db.js +109 -53
  65. package/dist/indexer/index-writer-lock.js +38 -37
  66. package/dist/indexer/index-written-assets.js +73 -56
  67. package/dist/indexer/indexer.js +5 -1
  68. package/dist/indexer/search/search-source.js +2 -2
  69. package/dist/indexer/usage/usage-events.js +8 -2
  70. package/dist/integrations/agent/engine-resolution.js +14 -7
  71. package/dist/scripts/migrate-storage.js +867 -990
  72. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
  73. package/dist/setup/detected-engines.js +7 -13
  74. package/dist/setup/engine-config.js +14 -4
  75. package/dist/setup/setup.js +1 -1
  76. package/dist/setup/steps/connection.js +1 -1
  77. package/dist/setup/steps/tasks.js +1 -1
  78. package/dist/sources/providers/git-stash.js +58 -21
  79. package/dist/sources/providers/git.js +1 -1
  80. package/dist/storage/engines/sqlite-migrations.js +138 -3
  81. package/dist/storage/repositories/events-repository.js +24 -0
  82. package/dist/storage/repositories/proposals-repository.js +14 -0
  83. package/dist/tasks/embedded.js +2 -0
  84. package/dist/workflows/db.js +24 -13
  85. package/dist/workflows/validator.js +24 -2
  86. package/docs/migration/release-notes/0.9.0.md +24 -7
  87. package/docs/migration/v0.8-to-v0.9.md +124 -19
  88. package/package.json +1 -1
@@ -31,33 +31,33 @@
31
31
  * cannot be moved alone, and target names ending `.derived` are rejected
32
32
  * (reserved suffix).
33
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).
34
+ * Ordering: the complete mutation holds the index-writer lease. After validation,
35
+ * citer replacements are staged beside durable byte-for-byte backups and a small
36
+ * phase journal under `.akm/mv-transactions/`. Publication uses same-filesystem
37
+ * renames; any synchronous failure restores every citer and asset rename. A later
38
+ * invocation rolls back an interrupted prepared/applying journal before planning
39
+ * another move. Derived index state remains fail-open and heals on a full index.
43
40
  * Graph tables (graph_files) key extractions by file path and stay stale
44
41
  * until the next graph pass — acceptable, the graph is a derived cache.
45
42
  */
43
+ import { createHash, randomUUID } from "node:crypto";
46
44
  import fs from "node:fs";
47
45
  import path from "node:path";
48
46
  import { defineJsonCommand, output } from "../cli/shared.js";
49
47
  import { parseAssetRef, refToString } from "../core/asset/asset-ref.js";
50
48
  import { deriveCanonicalAssetNameFromStashRoot, TYPE_DIRS } from "../core/asset/asset-spec.js";
51
49
  import { isWithin, resolveStashDir, toPosix } from "../core/common.js";
50
+ import { loadConfig } from "../core/config/config.js";
52
51
  import { UsageError } from "../core/errors.js";
53
- import { appendEvent } from "../core/events.js";
54
52
  import { getDbPath } from "../core/paths.js";
55
- import { getStateDbPath } from "../core/state-db.js";
53
+ import { getStateDbPath, openStateDatabase } from "../core/state-db.js";
56
54
  import { warnVerbose } from "../core/warn.js";
57
55
  import { closeDatabase, openExistingDatabase, rebuildFts, rekeyEntryInPlace } from "../indexer/db/db.js";
56
+ import { withAssetMutationLease } from "../indexer/index-writer-lock.js";
58
57
  import { indexWrittenAssets, WRITE_PATH_INDEX_BUSY_TIMEOUT_MS } from "../indexer/index-written-assets.js";
59
58
  import { resolveSourceEntries } from "../indexer/search/search-source.js";
60
- import { openDatabase } from "../storage/database.js";
59
+ import { insertEventOnce } from "../storage/repositories/events-repository.js";
60
+ import { shouldReadLegacyBareImproveState } from "./improve/source-identity.js";
61
61
  import { REF_BOUNDARY_PREFIX_CLASS_SRC, REF_SLUG_CHAR_CLASS_SRC, refToRelPath, resolveRefPathInStash, } from "./lint/base-linter.js";
62
62
  // ── Scope ─────────────────────────────────────────────────────────────────────
63
63
  /**
@@ -259,6 +259,300 @@ function collectCiterFiles(root) {
259
259
  walk(root);
260
260
  return results;
261
261
  }
262
+ let mvMutationHookForTests;
263
+ /** TEST-ONLY crash-window hook used by subprocess recovery tests. */
264
+ export function _setMvMutationHookForTests(hook) {
265
+ mvMutationHookForTests = hook;
266
+ }
267
+ function hashContent(content) {
268
+ return createHash("sha256").update(content).digest("hex");
269
+ }
270
+ function hashFile(filePath) {
271
+ return hashContent(fs.readFileSync(filePath));
272
+ }
273
+ function fsyncFile(filePath) {
274
+ const fd = fs.openSync(filePath, "r");
275
+ try {
276
+ fs.fsyncSync(fd);
277
+ }
278
+ finally {
279
+ fs.closeSync(fd);
280
+ }
281
+ }
282
+ function fsyncDirectory(dirPath) {
283
+ try {
284
+ fsyncFile(dirPath);
285
+ }
286
+ catch {
287
+ // Some platforms do not permit opening directories; file fsync still applies.
288
+ }
289
+ }
290
+ function writeMoveJournal(journalPath, journal) {
291
+ const tempPath = `${journalPath}.tmp`;
292
+ fs.writeFileSync(tempPath, `${JSON.stringify(journal, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
293
+ fsyncFile(tempPath);
294
+ fs.renameSync(tempPath, journalPath);
295
+ fsyncDirectory(path.dirname(journalPath));
296
+ }
297
+ function setMoveJournalPhase(transaction, phase) {
298
+ const next = { ...transaction.journal, phase };
299
+ writeMoveJournal(transaction.journalPath, next);
300
+ transaction.journal.phase = phase;
301
+ }
302
+ function rollbackMoveJournal(journal) {
303
+ const restoreRename = (oldPath, newPath, originalHash, publishedHash) => {
304
+ if (!oldPath || !newPath || !fs.existsSync(newPath))
305
+ return;
306
+ if (!publishedHash || hashFile(newPath) !== publishedHash) {
307
+ throw new Error(`cannot roll back ${newPath}: published file diverged after the move`);
308
+ }
309
+ if (fs.existsSync(oldPath)) {
310
+ if (hashFile(oldPath) !== publishedHash) {
311
+ throw new Error(`cannot roll back ${newPath}: source path ${oldPath} is occupied by divergent content`);
312
+ }
313
+ fs.unlinkSync(newPath);
314
+ return;
315
+ }
316
+ fs.linkSync(newPath, oldPath);
317
+ fs.unlinkSync(newPath);
318
+ if (originalHash && hashFile(oldPath) !== publishedHash) {
319
+ throw new Error(`cannot verify rolled-back source ${oldPath}`);
320
+ }
321
+ };
322
+ // Undo asset publication before restoring self-citing files at their old paths.
323
+ restoreRename(journal.oldPath, journal.newPath, journal.sourceOriginalHash, journal.expectedNewHash);
324
+ restoreRename(journal.twinOldPath, journal.twinNewPath, journal.twinOriginalHash, journal.expectedTwinNewHash);
325
+ for (const [index, citer] of journal.citers.entries()) {
326
+ if (!fs.existsSync(citer.backupPath)) {
327
+ throw new Error(`cannot restore ${citer.absPath}: backup is missing`);
328
+ }
329
+ const currentHash = fs.existsSync(citer.absPath) ? hashFile(citer.absPath) : null;
330
+ if (fs.existsSync(citer.ownedPath)) {
331
+ if (currentHash !== null && currentHash !== citer.replacementHash && currentHash !== citer.originalHash) {
332
+ throw new Error(`cannot restore ${citer.absPath}: file diverged after exclusive ownership`);
333
+ }
334
+ if (currentHash === citer.replacementHash)
335
+ fs.unlinkSync(citer.absPath);
336
+ if (!fs.existsSync(citer.absPath))
337
+ fs.linkSync(citer.ownedPath, citer.absPath);
338
+ continue;
339
+ }
340
+ if (currentHash === citer.originalHash)
341
+ continue;
342
+ if (currentHash !== citer.replacementHash) {
343
+ throw new Error(`cannot restore ${citer.absPath}: file diverged after move planning`);
344
+ }
345
+ const restorePath = path.join(path.dirname(citer.backupPath), `restore-${index}`);
346
+ fs.copyFileSync(citer.backupPath, restorePath);
347
+ fs.chmodSync(restorePath, citer.mode);
348
+ fs.renameSync(restorePath, citer.absPath);
349
+ }
350
+ }
351
+ function validateCommittedMove(journal) {
352
+ if (fs.existsSync(journal.oldPath) || !fs.existsSync(journal.newPath)) {
353
+ throw new Error(`Cannot finalize move: expected only committed target ${journal.newPath}.`);
354
+ }
355
+ if (hashFile(journal.newPath) !== journal.expectedNewHash) {
356
+ throw new Error(`Cannot finalize move: committed target ${journal.newPath} diverged.`);
357
+ }
358
+ if (journal.twinNewPath) {
359
+ if (journal.twinOldPath && fs.existsSync(journal.twinOldPath)) {
360
+ throw new Error(`Cannot finalize move: old twin ${journal.twinOldPath} still exists.`);
361
+ }
362
+ if (!fs.existsSync(journal.twinNewPath) || hashFile(journal.twinNewPath) !== journal.expectedTwinNewHash) {
363
+ throw new Error(`Cannot finalize move: committed twin ${journal.twinNewPath} diverged.`);
364
+ }
365
+ }
366
+ for (const citer of journal.citers) {
367
+ if (citer.absPath === journal.oldPath || citer.absPath === journal.twinOldPath)
368
+ continue;
369
+ if (!fs.existsSync(citer.absPath) || hashFile(citer.absPath) !== citer.replacementHash) {
370
+ throw new Error(`Cannot finalize move: citer ${citer.absPath} diverged.`);
371
+ }
372
+ }
373
+ }
374
+ function cleanupMoveTransaction(transactionDir) {
375
+ try {
376
+ fs.rmSync(transactionDir, { recursive: true, force: true });
377
+ const root = path.dirname(transactionDir);
378
+ try {
379
+ fs.rmdirSync(root);
380
+ }
381
+ catch {
382
+ // Other transactions may still exist.
383
+ }
384
+ return null;
385
+ }
386
+ catch (error) {
387
+ const warning = `move committed but journal cleanup failed at ${transactionDir}: ${error instanceof Error ? error.message : String(error)}`;
388
+ warnVerbose(`akm mv: ${warning}`);
389
+ return warning;
390
+ }
391
+ }
392
+ export async function recoverInterruptedMoveTransactions(stashDir) {
393
+ const root = path.join(stashDir, ".akm", "mv-transactions");
394
+ if (!fs.existsSync(root))
395
+ return;
396
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
397
+ if (!entry.isDirectory())
398
+ continue;
399
+ const transactionDir = path.join(root, entry.name);
400
+ const journalPath = path.join(transactionDir, "journal.json");
401
+ if (!fs.existsSync(journalPath)) {
402
+ cleanupMoveTransaction(transactionDir);
403
+ continue;
404
+ }
405
+ let journal;
406
+ try {
407
+ journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
408
+ }
409
+ catch (error) {
410
+ throw new Error(`Cannot recover interrupted move journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`);
411
+ }
412
+ if (journal.version !== 1)
413
+ throw new Error(`Unsupported move journal version at ${journalPath}.`);
414
+ const stashPaths = [journal.oldPath, journal.newPath, journal.twinOldPath, journal.twinNewPath]
415
+ .concat(journal.citers.map((citer) => citer.absPath))
416
+ .filter((candidate) => candidate !== null);
417
+ const transactionPaths = journal.citers.flatMap((citer) => [citer.backupPath, citer.stagedPath, citer.ownedPath]);
418
+ if (![
419
+ "prepared",
420
+ "applying",
421
+ "filesystem-committed",
422
+ "index-finalized",
423
+ "state-finalized",
424
+ "event-finalized",
425
+ "committed",
426
+ ].includes(journal.phase) ||
427
+ stashPaths.some((candidate) => !isWithin(candidate, stashDir)) ||
428
+ transactionPaths.some((candidate) => !isWithin(candidate, transactionDir))) {
429
+ throw new Error(`Refusing unsafe move recovery journal at ${journalPath}.`);
430
+ }
431
+ const transaction = { journal, journalPath, transactionDir };
432
+ if (journal.phase === "prepared" || journal.phase === "applying") {
433
+ rollbackMoveJournal(journal);
434
+ }
435
+ else if (journal.phase !== "committed") {
436
+ validateCommittedMove(journal);
437
+ await finalizeMoveTransaction(transaction);
438
+ }
439
+ cleanupMoveTransaction(transactionDir);
440
+ }
441
+ }
442
+ function applyMoveFilesystem(opts) {
443
+ const transactionRoot = path.join(opts.stashDir, ".akm", "mv-transactions");
444
+ fs.mkdirSync(transactionRoot, { recursive: true, mode: 0o700 });
445
+ const transactionId = randomUUID();
446
+ const transactionDir = path.join(transactionRoot, transactionId);
447
+ fs.mkdirSync(transactionDir, { mode: 0o700 });
448
+ const journalPath = path.join(transactionDir, "journal.json");
449
+ let journal;
450
+ try {
451
+ const citers = opts.plans.map((plan, index) => {
452
+ const mode = fs.statSync(plan.absPath).mode;
453
+ const backupPath = path.join(transactionDir, `backup-${index}`);
454
+ const stagedPath = path.join(transactionDir, `staged-${index}`);
455
+ const ownedPath = path.join(transactionDir, `owned-${index}`);
456
+ if (hashFile(plan.absPath) !== plan.originalHash) {
457
+ throw new Error(`refusing to stage divergent citer ${plan.absPath}`);
458
+ }
459
+ fs.copyFileSync(plan.absPath, backupPath);
460
+ fs.chmodSync(backupPath, mode);
461
+ fs.writeFileSync(stagedPath, plan.content, { encoding: "utf8", mode });
462
+ fsyncFile(backupPath);
463
+ fsyncFile(stagedPath);
464
+ return {
465
+ absPath: plan.absPath,
466
+ backupPath,
467
+ stagedPath,
468
+ ownedPath,
469
+ mode,
470
+ originalHash: plan.originalHash,
471
+ replacementHash: hashContent(plan.content),
472
+ };
473
+ });
474
+ if (hashFile(opts.oldPath) !== opts.sourceOriginalHash)
475
+ throw new Error(`source ${opts.oldPath} diverged`);
476
+ if (opts.twinOldPath && opts.twinOriginalHash && hashFile(opts.twinOldPath) !== opts.twinOriginalHash) {
477
+ throw new Error(`twin ${opts.twinOldPath} diverged`);
478
+ }
479
+ const sourceCiter = citers.find((citer) => citer.absPath === opts.oldPath);
480
+ const twinCiter = citers.find((citer) => citer.absPath === opts.twinOldPath);
481
+ journal = {
482
+ version: 1,
483
+ phase: "prepared",
484
+ transactionId,
485
+ sourceName: opts.sourceName,
486
+ sourceRoot: opts.sourceRoot,
487
+ includeLegacyBare: opts.includeLegacyBare,
488
+ eventTs: new Date().toISOString(),
489
+ eventMetadata: opts.eventMetadata,
490
+ oldPath: opts.oldPath,
491
+ newPath: opts.newPath,
492
+ twinOldPath: opts.twinOldPath,
493
+ twinNewPath: opts.twinNewPath,
494
+ sourceOriginalHash: opts.sourceOriginalHash,
495
+ expectedNewHash: sourceCiter?.replacementHash ?? opts.sourceOriginalHash,
496
+ twinOriginalHash: opts.twinOriginalHash,
497
+ expectedTwinNewHash: twinCiter?.replacementHash ?? opts.twinOriginalHash,
498
+ type: opts.type,
499
+ oldName: opts.oldName,
500
+ newName: opts.newName,
501
+ fromRef: opts.fromRef,
502
+ toRef: opts.toRef,
503
+ citers,
504
+ };
505
+ writeMoveJournal(journalPath, journal);
506
+ const transaction = { journal, journalPath, transactionDir };
507
+ setMoveJournalPhase(transaction, "applying");
508
+ for (const citer of citers) {
509
+ fs.renameSync(citer.absPath, citer.ownedPath);
510
+ if (hashFile(citer.ownedPath) !== citer.originalHash) {
511
+ if (!fs.existsSync(citer.absPath))
512
+ fs.linkSync(citer.ownedPath, citer.absPath);
513
+ throw new Error(`refusing to replace divergent citer ${citer.absPath}`);
514
+ }
515
+ try {
516
+ fs.linkSync(citer.stagedPath, citer.absPath);
517
+ fs.unlinkSync(citer.stagedPath);
518
+ }
519
+ catch (error) {
520
+ if (!fs.existsSync(citer.absPath))
521
+ fs.linkSync(citer.ownedPath, citer.absPath);
522
+ throw error;
523
+ }
524
+ }
525
+ if (hashFile(opts.oldPath) !== journal.expectedNewHash)
526
+ throw new Error(`source ${opts.oldPath} diverged`);
527
+ fs.linkSync(opts.oldPath, opts.newPath);
528
+ fs.unlinkSync(opts.oldPath);
529
+ if (opts.twinOldPath && opts.twinNewPath) {
530
+ if (hashFile(opts.twinOldPath) !== journal.expectedTwinNewHash)
531
+ throw new Error(`twin ${opts.twinOldPath} diverged`);
532
+ fs.linkSync(opts.twinOldPath, opts.twinNewPath);
533
+ fs.unlinkSync(opts.twinOldPath);
534
+ }
535
+ setMoveJournalPhase(transaction, "filesystem-committed");
536
+ return transaction;
537
+ }
538
+ catch (error) {
539
+ if (journal) {
540
+ try {
541
+ rollbackMoveJournal(journal);
542
+ cleanupMoveTransaction(transactionDir);
543
+ }
544
+ catch (rollbackError) {
545
+ throw new Error(`Move failed (${error instanceof Error ? error.message : String(error)}) and rollback failed ` +
546
+ `(${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}). ` +
547
+ `Recovery journal retained at ${journalPath}.`);
548
+ }
549
+ }
550
+ else {
551
+ cleanupMoveTransaction(transactionDir);
552
+ }
553
+ throw error;
554
+ }
555
+ }
262
556
  // ── Source resolution ─────────────────────────────────────────────────────────
263
557
  /**
264
558
  * Return the ON-DISK casing of `relPath` under `root` as a posix-separated
@@ -390,7 +684,7 @@ function rekeyIndexForMove(opts) {
390
684
  const dbPath = getDbPath();
391
685
  try {
392
686
  if (!fs.existsSync(dbPath))
393
- return { preserved: true, warning: null };
687
+ return { complete: true, preserved: true, warning: null };
394
688
  let preserved = true;
395
689
  const db = openExistingDatabase(dbPath);
396
690
  try {
@@ -401,6 +695,8 @@ function rekeyIndexForMove(opts) {
401
695
  const strandedRow = (movedFrom) => db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(movedFrom) != null;
402
696
  const oldKey = `${opts.stashDir}:${opts.type}:${opts.oldName}`;
403
697
  const newKey = `${opts.stashDir}:${opts.type}:${opts.newName}`;
698
+ const alreadyRekeyed = db.prepare("SELECT id FROM entries WHERE entry_key = ? AND file_path = ? LIMIT 1").get(newKey, opts.newPath) !=
699
+ null;
404
700
  const rekeyed = rekeyEntryInPlace(db, {
405
701
  oldEntryKey: oldKey,
406
702
  newEntryKey: newKey,
@@ -408,13 +704,19 @@ function rekeyIndexForMove(opts) {
408
704
  newFilePath: opts.newPath,
409
705
  oldRef: opts.fromRef,
410
706
  newRef: opts.toRef,
707
+ sourceName: opts.sourceName,
708
+ sourceRoot: opts.sourceRoot,
709
+ includeLegacyBare: opts.includeLegacyBare,
411
710
  });
412
- if (rekeyed === null && strandedRow(opts.oldPath))
711
+ if (rekeyed === null && !alreadyRekeyed && strandedRow(opts.oldPath))
413
712
  preserved = false;
414
713
  let twinRekeyed = null;
415
714
  if (opts.twinNewPath) {
416
715
  // The twin coupling (db.ts getBaseBeliefStatesForDerivedTwins) is
417
716
  // `twin entry_key === base entry_key + ".derived"` — preserved here.
717
+ const twinAlreadyRekeyed = db
718
+ .prepare("SELECT id FROM entries WHERE entry_key = ? AND file_path = ? LIMIT 1")
719
+ .get(`${newKey}.derived`, opts.twinNewPath) != null;
418
720
  twinRekeyed = rekeyEntryInPlace(db, {
419
721
  oldEntryKey: `${oldKey}.derived`,
420
722
  newEntryKey: `${newKey}.derived`,
@@ -423,13 +725,18 @@ function rekeyIndexForMove(opts) {
423
725
  oldRef: `${opts.fromRef}.derived`,
424
726
  newRef: `${opts.toRef}.derived`,
425
727
  newDerivedFrom: opts.toRef,
728
+ sourceName: opts.sourceName,
729
+ sourceRoot: opts.sourceRoot,
730
+ includeLegacyBare: opts.includeLegacyBare,
426
731
  });
427
- if (twinRekeyed === null && opts.twinOldPath && strandedRow(opts.twinOldPath))
732
+ if (twinRekeyed === null && !twinAlreadyRekeyed && opts.twinOldPath && strandedRow(opts.twinOldPath)) {
428
733
  preserved = false;
734
+ }
429
735
  }
430
736
  if (rekeyed !== null || twinRekeyed !== null) {
431
737
  rebuildFts(db, { incremental: true });
432
738
  }
739
+ mvMutationHookForTests?.("index-rekeyed");
433
740
  }
434
741
  finally {
435
742
  closeDatabase(db);
@@ -438,16 +745,16 @@ function rekeyIndexForMove(opts) {
438
745
  const warning = "index re-key skipped: the index holds a row for the moved file under an unexpected key — its utility " +
439
746
  "history was not re-keyed and resets on the next `akm index`.";
440
747
  warnVerbose(`akm mv: ${warning}`);
441
- return { preserved: false, warning };
748
+ return { complete: false, preserved: false, warning };
442
749
  }
443
- return { preserved: true, warning: null };
750
+ return { complete: true, preserved: true, warning: null };
444
751
  }
445
752
  catch (error) {
446
753
  const message = error instanceof Error ? error.message : String(error);
447
754
  const warning = `index re-key failed (${message}) — the rename itself succeeded and the index heals on the next ` +
448
755
  "`akm index`, but the asset's utility history was NOT re-keyed and resets on that run.";
449
756
  warnVerbose(`akm mv: ${warning}`);
450
- return { preserved: false, warning };
757
+ return { complete: false, preserved: false, warning };
451
758
  }
452
759
  }
453
760
  /**
@@ -465,21 +772,35 @@ function rekeyIndexForMove(opts) {
465
772
  * no file exists at the target — so the LIVE asset's history wins: the
466
773
  * orphan row is deleted and the moved asset's row re-keyed onto the ref.
467
774
  *
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.
775
+ * No state.db means the improve loop never ran and is complete as a no-op. A
776
+ * legacy missing table is likewise complete. Other failures retain the
777
+ * committed move journal and block completion so a later mutation retries the
778
+ * non-regenerable state update rather than silently stranding it.
473
779
  */
474
- function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
780
+ function rekeyStateDbForMove(fromRef, toRef, includeTwin, sourceName, sourceRoot, includeLegacyBare) {
475
781
  const statePath = getStateDbPath();
476
782
  try {
477
783
  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);
784
+ return { complete: true, warning: null };
785
+ if (!sourceName || !sourceRoot)
786
+ return { complete: false, warning: "move source identity is unavailable" };
787
+ const origins = new Set([sourceName]);
788
+ if (sourceName === "stash" && includeLegacyBare)
789
+ origins.add("local");
790
+ const pairs = [...origins].map((origin) => [
791
+ `${origin}//${fromRef}`,
792
+ `${origin}//${toRef}`,
793
+ ]);
794
+ if (includeLegacyBare)
795
+ pairs.push([fromRef, toRef]);
796
+ if (includeTwin) {
797
+ for (const origin of origins) {
798
+ pairs.push([`${origin}//${fromRef}.derived`, `${origin}//${toRef}.derived`]);
799
+ }
800
+ if (includeLegacyBare)
801
+ pairs.push([`${fromRef}.derived`, `${toRef}.derived`]);
802
+ }
803
+ const db = openStateDatabase();
483
804
  const tableFailures = [];
484
805
  try {
485
806
  db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
@@ -494,6 +815,7 @@ function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
494
815
  db.prepare(`UPDATE ${table} SET asset_ref = ? WHERE asset_ref = ?`).run(newRef, oldRef);
495
816
  }
496
817
  })();
818
+ mvMutationHookForTests?.(`state-${table}-rekeyed`);
497
819
  }
498
820
  catch (error) {
499
821
  const message = error instanceof Error ? error.message : String(error);
@@ -514,18 +836,97 @@ function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
514
836
  const warning = `state.db salience re-key failed (${tableFailures.join("; ")}) — the rename itself succeeded, but the ` +
515
837
  "asset's salience/outcome history stays keyed to the old ref until the next improve run re-mints it.";
516
838
  warnVerbose(`akm mv: ${warning}`);
517
- return warning;
839
+ return { complete: false, warning };
518
840
  }
519
- return null;
841
+ return { complete: true, warning: null };
520
842
  }
521
843
  catch (error) {
522
844
  const message = error instanceof Error ? error.message : String(error);
523
845
  const warning = `state.db salience re-key failed (${message}) — the rename itself succeeded, but the asset's salience/` +
524
846
  "outcome history stays keyed to the old ref until the next improve run re-mints it.";
525
847
  warnVerbose(`akm mv: ${warning}`);
526
- return warning;
848
+ return { complete: false, warning };
527
849
  }
528
850
  }
851
+ function persistMoveEvent(journal) {
852
+ const db = openStateDatabase();
853
+ try {
854
+ db.transaction(() => {
855
+ insertEventOnce(db, {
856
+ eventType: "mv",
857
+ ts: journal.eventTs,
858
+ ref: journal.toRef,
859
+ metadata: {
860
+ ...journal.eventMetadata,
861
+ mutationTransactionId: journal.transactionId,
862
+ },
863
+ idempotencyKey: journal.transactionId,
864
+ idempotencyMetadataKey: "mutationTransactionId",
865
+ });
866
+ })();
867
+ }
868
+ finally {
869
+ db.close();
870
+ }
871
+ }
872
+ async function finalizeMoveTransaction(transaction) {
873
+ const { journal } = transaction;
874
+ validateCommittedMove(journal);
875
+ const warnings = [];
876
+ let utilityPreserved = true;
877
+ if (journal.phase === "filesystem-committed") {
878
+ const indexResult = rekeyIndexForMove({
879
+ stashDir: path.dirname(path.dirname(path.dirname(transaction.transactionDir))),
880
+ type: journal.type,
881
+ oldName: journal.oldName,
882
+ newName: journal.newName,
883
+ oldPath: journal.oldPath,
884
+ newPath: journal.newPath,
885
+ fromRef: journal.fromRef,
886
+ toRef: journal.toRef,
887
+ twinOldPath: journal.twinOldPath,
888
+ twinNewPath: journal.twinNewPath,
889
+ sourceName: journal.sourceName,
890
+ sourceRoot: journal.sourceRoot,
891
+ includeLegacyBare: journal.includeLegacyBare,
892
+ });
893
+ utilityPreserved = indexResult.preserved;
894
+ if (indexResult.warning)
895
+ warnings.push(indexResult.warning);
896
+ if (!indexResult.complete)
897
+ throw new Error(indexResult.warning ?? "move index re-key did not complete");
898
+ const touched = new Set([journal.newPath]);
899
+ if (journal.twinNewPath)
900
+ touched.add(journal.twinNewPath);
901
+ for (const citer of journal.citers) {
902
+ if (citer.absPath === journal.oldPath || citer.absPath === journal.twinOldPath)
903
+ continue;
904
+ touched.add(citer.absPath);
905
+ }
906
+ const stashDir = path.dirname(path.dirname(path.dirname(transaction.transactionDir)));
907
+ if (!(await indexWrittenAssets(stashDir, [...touched], { recoverMoves: false }))) {
908
+ utilityPreserved = false;
909
+ warnings.push("write-path index refresh failed; the derived index will heal on the next full index");
910
+ }
911
+ setMoveJournalPhase(transaction, "index-finalized");
912
+ }
913
+ if (journal.phase === "index-finalized") {
914
+ const stateResult = rekeyStateDbForMove(journal.fromRef, journal.toRef, journal.twinNewPath !== null, journal.sourceName, journal.sourceRoot, journal.includeLegacyBare);
915
+ if (stateResult.warning)
916
+ warnings.push(stateResult.warning);
917
+ if (!stateResult.complete)
918
+ throw new Error(stateResult.warning ?? "move state finalization did not complete");
919
+ setMoveJournalPhase(transaction, "state-finalized");
920
+ }
921
+ if (journal.phase === "state-finalized") {
922
+ persistMoveEvent(journal);
923
+ mvMutationHookForTests?.("mv-event-persisted");
924
+ setMoveJournalPhase(transaction, "event-finalized");
925
+ }
926
+ if (journal.phase === "event-finalized")
927
+ setMoveJournalPhase(transaction, "committed");
928
+ return { utilityPreserved, warnings };
929
+ }
529
930
  // ── Command ───────────────────────────────────────────────────────────────────
530
931
  export const mvCommand = defineJsonCommand({
531
932
  meta: {
@@ -566,161 +967,145 @@ export const mvCommand = defineJsonCommand({
566
967
  if (!refArg || !targetArg) {
567
968
  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
969
  }
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");
970
+ await withAssetMutationLease("mv", async () => {
971
+ // ── Validation (everything before any write; a failure moves nothing) ──
972
+ const source = parseAssetRef(refArg);
973
+ if (source.origin && source.origin !== "local") {
974
+ throw new UsageError(`akm mv operates on the primary writable stash only — the origin prefix "${source.origin}//" is not supported.`, "INVALID_FLAG_VALUE");
702
975
  }
703
- catch {
704
- continue;
976
+ if (source.type === "wiki") {
977
+ throw new UsageError("akm mv does not support wiki refs — wiki pages have their own xref + lint system. " +
978
+ "Rename the page manually, fix citations in the same pass, and verify with `akm wiki lint <name>`.", "INVALID_FLAG_VALUE");
705
979
  }
706
- const { content, count } = rewriteRefs(raw, rewriteCtx);
707
- if (count > 0) {
708
- plans.push({ absPath, relPath: toPosix(path.relative(stashDir, absPath)), count, content });
980
+ if (source.type === "workflow") {
981
+ throw new UsageError("akm mv does not support workflow refs in v1 — workflows may live as .yaml/.yml programs, which the " +
982
+ "flat-markdown rename path would misresolve or rename to .md. Rename the file manually under " +
983
+ "workflows/ (keeping its extension), fix inbound refs in the same pass, and verify with `akm lint`.", "INVALID_FLAG_VALUE");
709
984
  }
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)) {
985
+ if (!MV_SUPPORTED_TYPES.includes(source.type)) {
986
+ throw new UsageError(`akm mv supports flat-markdown asset types (${MV_SUPPORTED_TYPES.join(", ")}); "${source.type}:" refs cannot be moved.`, "INVALID_FLAG_VALUE");
987
+ }
988
+ // The `.derived` suffix is the distilled-twin marker: a twin's entry_key
989
+ // must stay exactly `<base entry_key>.derived` (db.ts
990
+ // getBaseBeliefStatesForDerivedTwins), so a twin can never move alone and
991
+ // no independent asset may squat on the suffix. The `.md`-suffixed alias
992
+ // spelling of a twin ref names the same file, so it is caught here too.
993
+ if (source.type === "memory" && /\.derived(\.md)?$/.test(source.name)) {
994
+ const baseRef = refToString({ type: "memory", name: source.name.replace(/\.derived(\.md)?$/, "") });
995
+ throw new UsageError(`"${refToString({ type: source.type, name: source.name })}" names a .derived.md distilled twin — a twin ` +
996
+ "cannot be moved on its own without breaking its belief-inheritance coupling to the base memory. " +
997
+ `Rename the base ref instead (akm mv ${baseRef} <new-name>); the twin moves with it.`, "INVALID_FLAG_VALUE");
998
+ }
999
+ // The target may be a bare name ("projectA/new-note") or a ref-shaped
1000
+ // spelling. Parsing the bare form through the same ref grammar gives it
1001
+ // identical name validation (traversal, null bytes, absolute paths).
1002
+ const target = parseAssetRef(targetArg.includes(":") ? targetArg : `${source.type}:${targetArg}`);
1003
+ if (target.origin) {
1004
+ throw new UsageError(`The target must be a name within the ${source.type} type — origin prefixes are not supported.`, "INVALID_FLAG_VALUE");
1005
+ }
1006
+ if (target.type !== source.type) {
1007
+ throw new UsageError(`Cross-type move is not supported: "${refToString({ type: source.type, name: source.name })}" is a ` +
1008
+ `${source.type}: asset but the target names the ${target.type}: type. akm mv renames within one asset type.`, "INVALID_FLAG_VALUE");
1009
+ }
1010
+ // Accept the `.md`-suffixed alias spelling of the TARGET, but operate on
1011
+ // the canonical extensionless name: every MV_SUPPORTED_TYPES layout is
1012
+ // the markdownSpec family, whose `toAssetPath` writes `<name>.md` either
1013
+ // way — so `bar.md` names the same file as `bar`, while a `bar.md`-keyed
1014
+ // toRef/entry_key would rewrite citers to a non-canonical ref and strand
1015
+ // the re-keyed history behind a row the write-path index pass (which
1016
+ // derives the canonical name `bar` from the file) immediately duplicates.
1017
+ const newName = target.name.endsWith(".md") ? target.name.slice(0, -".md".length) : target.name;
1018
+ if (!newName) {
1019
+ throw new UsageError(`Target "${targetArg}" names no asset once the .md extension is stripped — nothing moved.`, "INVALID_FLAG_VALUE");
1020
+ }
1021
+ // Reject empty path segments: `path.posix.normalize` (parseAssetRef's
1022
+ // name normalization) PRESERVES a trailing slash — "bar/" (and "bar\",
1023
+ // normalized to it) sails through the traversal checks, and the file
1024
+ // would land at e.g. memories/bar/.md: a dot-prefixed file the index
1025
+ // walker skips, unreachable by `akm show`, with every citer rewritten to
1026
+ // the phantom ref "memory:bar/". Interior doubles ("a//b") are collapsed
1027
+ // by the normalization, so a trailing empty segment is the only shape
1028
+ // that reaches this check — but reject ANY empty segment regardless.
1029
+ if (newName.split("/").some((segment) => segment.length === 0)) {
1030
+ throw new UsageError(`Target "${targetArg}" contains an empty path segment (trailing "/" or "\\") — the file would be written ` +
1031
+ "as a hidden dotfile the index cannot see. Pass a name, e.g. `akm mv <ref> projectA/new-note` — nothing moved.", "INVALID_FLAG_VALUE");
1032
+ }
1033
+ if (source.type === "memory" && newName.endsWith(".derived")) {
1034
+ throw new UsageError(`The target name "${newName}" ends with the reserved .derived suffix (the distilled-twin marker) — a base ` +
1035
+ "memory renamed onto it would masquerade as a twin of a memory that does not exist. Pick a name without " +
1036
+ "the suffix; a real twin always moves together with its base.", "INVALID_FLAG_VALUE");
1037
+ }
1038
+ const toRef = refToString({ type: source.type, name: newName });
1039
+ const stashDir = resolveStashDir();
1040
+ const config = loadConfig();
1041
+ const configuredSources = resolveSourceEntries(stashDir, config);
1042
+ const primarySource = configuredSources.find((entry) => path.resolve(entry.path) === path.resolve(stashDir));
1043
+ const durableSourceName = primarySource?.registryId ?? "stash";
1044
+ const includeLegacyBare = shouldReadLegacyBareImproveState(durableSourceName, stashDir, config);
1045
+ await recoverInterruptedMoveTransactions(stashDir);
1046
+ const typeDir = TYPE_DIRS[source.type];
1047
+ const typeRoot = path.join(stashDir, typeDir);
1048
+ const oldRelPath = refToRelPath(source.type, source.name);
1049
+ const newRelPath = refToRelPath(source.type, newName);
1050
+ if (!oldRelPath || !newRelPath) {
1051
+ // Unreachable for MV_SUPPORTED_TYPES; guards a future registry change.
1052
+ throw new UsageError(`"${source.type}:" refs are not path-resolvable and cannot be moved.`, "INVALID_FLAG_VALUE");
1053
+ }
1054
+ const oldPath = resolveMoveSourcePath(stashDir, oldRelPath, source.type, source.name);
1055
+ if (!oldPath) {
1056
+ throw new UsageError(`Cannot resolve ${refToString({ type: source.type, name: source.name })} in the writable stash at ` +
1057
+ `${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`.");
1058
+ }
1059
+ // The accepted spelling may be the `.md`-suffixed alias of the same file
1060
+ // (markdownSpec.toAssetPath maps `foo` and `foo.md` to memories/foo.md).
1061
+ // Everything keyed off the source — the citer rewrite patterns, the index
1062
+ // entry_key re-key, the state.db asset_ref re-key, the report — must use
1063
+ // the CANONICAL extensionless name derived from the resolved path, or the
1064
+ // real rows (keyed by the canonical spelling) are silently missed.
1065
+ const sourceName = deriveCanonicalAssetNameFromStashRoot(source.type, stashDir, oldPath) ?? source.name;
1066
+ const fromRef = refToString({ type: source.type, name: sourceName });
1067
+ const newPath = path.join(stashDir, newRelPath);
1068
+ // Defense-in-depth: parseAssetRef already rejects `../` traversal, but the
1069
+ // computed target must land inside the type root regardless.
1070
+ if (!isWithin(newPath, typeRoot)) {
1071
+ throw new UsageError(`Target "${targetArg}" escapes the ${typeDir}/ type root — nothing moved.`, "PATH_ESCAPE_VIOLATION");
1072
+ }
1073
+ if (path.resolve(newPath) === path.resolve(oldPath)) {
1074
+ throw new UsageError(`Source and target resolve to the same file (${fromRef}) — nothing to move.`);
1075
+ }
1076
+ if (fs.existsSync(newPath)) {
1077
+ 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.");
1078
+ }
1079
+ // Memory `.derived.md` twin: moves together with its base (the entry_key
1080
+ // suffix coupling the belief-state inheritance relies on). The TARGET
1081
+ // twin-collision check runs whenever the target could carry a twin —
1082
+ // NOT only when the source has one: renaming a twin-less memory onto a
1083
+ // name whose orphaned `<name>.derived.md` lingers (consolidate/dedup
1084
+ // delete the base file without twin cleanup) would silently adopt the
1085
+ // stranger file as the renamed memory's distillation.
1086
+ const isBaseMemory = source.type === "memory" && !sourceName.endsWith(".derived");
1087
+ const twinOldPath = isBaseMemory ? oldPath.replace(/\.md$/, ".derived.md") : null;
1088
+ const hasTwin = twinOldPath !== null && fs.existsSync(twinOldPath);
1089
+ const targetTwinPath = isBaseMemory ? newPath.replace(/\.md$/, ".derived.md") : null;
1090
+ if (targetTwinPath && fs.existsSync(targetTwinPath)) {
1091
+ throw new UsageError(`Target twin ${toRef}.derived already exists at ${toPosix(path.relative(stashDir, targetTwinPath))} — ` +
1092
+ "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.");
1093
+ }
1094
+ const twinNewPath = hasTwin ? targetTwinPath : null;
1095
+ const sourceOriginalHash = hashFile(oldPath);
1096
+ const twinOriginalHash = hasTwin && twinOldPath ? hashFile(twinOldPath) : null;
1097
+ // ── Plan the inbound-ref rewrite (no writes yet) ───────────────────────
1098
+ const rewriteCtx = buildRewriteContext({
1099
+ type: source.type,
1100
+ fromRef,
1101
+ toRef,
1102
+ isBaseMemory,
1103
+ stashDir,
1104
+ oldPath,
1105
+ twinOldPath: hasTwin ? twinOldPath : null,
1106
+ });
1107
+ const plans = [];
1108
+ for (const absPath of collectCiterFiles(stashDir)) {
724
1109
  let raw;
725
1110
  try {
726
1111
  raw = fs.readFileSync(absPath, "utf8");
@@ -728,82 +1113,86 @@ export const mvCommand = defineJsonCommand({
728
1113
  catch {
729
1114
  continue;
730
1115
  }
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: {
1116
+ const { content, count } = rewriteRefs(raw, rewriteCtx);
1117
+ if (count > 0) {
1118
+ plans.push({
1119
+ absPath,
1120
+ relPath: toPosix(path.relative(stashDir, absPath)),
1121
+ count,
1122
+ content,
1123
+ originalHash: hashContent(raw),
1124
+ });
1125
+ }
1126
+ }
1127
+ // Read-only sources: scanned, never written manual follow-ups.
1128
+ const readOnlyCiters = [];
1129
+ for (const src of configuredSources) {
1130
+ if (path.resolve(src.path) === path.resolve(stashDir))
1131
+ continue;
1132
+ for (const absPath of collectCiterFiles(src.path)) {
1133
+ let raw;
1134
+ try {
1135
+ raw = fs.readFileSync(absPath, "utf8");
1136
+ }
1137
+ catch {
1138
+ continue;
1139
+ }
1140
+ // Same detection as the writable pass (canonical + alias spellings,
1141
+ // alias tokens resolved against the WRITABLE stash where the moved
1142
+ // file lives) — count-only, never written.
1143
+ const { count } = rewriteRefs(raw, rewriteCtx);
1144
+ if (count > 0)
1145
+ readOnlyCiters.push({ file: absPath, count });
1146
+ }
1147
+ }
1148
+ // ── Apply citer edits, then rename last (see module docstring) ────────
1149
+ // The target's parent directory is created FIRST: if it cannot be (a
1150
+ // segment of the target's subdirectory path exists as a FILE, or the
1151
+ // parent is unwritable), the command must abort before any citer has
1152
+ // been edited otherwise citers would already point at a ref whose
1153
+ // file never arrives.
1154
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
1155
+ const transaction = applyMoveFilesystem({
1156
+ stashDir,
1157
+ oldPath,
1158
+ newPath,
1159
+ twinOldPath: hasTwin ? twinOldPath : null,
1160
+ twinNewPath,
1161
+ sourceOriginalHash,
1162
+ twinOriginalHash,
1163
+ type: source.type,
1164
+ oldName: sourceName,
1165
+ newName,
1166
+ fromRef,
1167
+ toRef,
1168
+ sourceName: durableSourceName,
1169
+ sourceRoot: stashDir,
1170
+ includeLegacyBare,
1171
+ eventMetadata: {
1172
+ from: fromRef,
1173
+ to: toRef,
1174
+ rewroteFiles: plans.length,
1175
+ readOnlyCiters: readOnlyCiters.length,
1176
+ twinMoved: hasTwin,
1177
+ },
1178
+ plans,
1179
+ });
1180
+ // Filesystem commit is irreversible. Any finalization error leaves the
1181
+ // journal for the next mutation to finish forward; it never rolls back.
1182
+ const finalized = await finalizeMoveTransaction(transaction);
1183
+ const cleanupWarning = cleanupMoveTransaction(transaction.transactionDir);
1184
+ const warnings = [...finalized.warnings, ...(cleanupWarning ? [cleanupWarning] : [])];
1185
+ output("mv", {
1186
+ ok: true,
790
1187
  from: fromRef,
791
1188
  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 } : {}),
1189
+ rewrote: plans.map((plan) => ({ file: plan.relPath, count: plan.count })),
1190
+ readOnlyCiters,
1191
+ utilityPreserved: finalized.utilityPreserved,
1192
+ // Additive: present only when a re-key could not be completed, so the
1193
+ // report (not just --verbose stderr) says WHY history may reset.
1194
+ ...(warnings.length > 0 ? { warnings } : {}),
1195
+ });
807
1196
  });
808
1197
  },
809
1198
  });