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

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 (115) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/improve-strategies/catchup.json +3 -1
  8. package/dist/assets/improve-strategies/consolidate.json +3 -1
  9. package/dist/assets/improve-strategies/frequent.json +3 -1
  10. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  11. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  12. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  13. package/dist/assets/improve-strategies/quick.json +3 -1
  14. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  15. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  16. package/dist/assets/improve-strategies/synthesize.json +2 -0
  17. package/dist/assets/tasks/core/backup.yml +2 -2
  18. package/dist/cli/config-migrate.js +554 -26
  19. package/dist/cli.js +28 -11
  20. package/dist/commands/backup-cli.js +6 -4
  21. package/dist/commands/config-cli.js +10 -3
  22. package/dist/commands/feedback-cli.js +24 -7
  23. package/dist/commands/health/checks.js +94 -15
  24. package/dist/commands/health/html-report.js +23 -2
  25. package/dist/commands/health/improve-metrics.js +45 -6
  26. package/dist/commands/health/md-report.js +10 -1
  27. package/dist/commands/health/surfaces.js +2 -1
  28. package/dist/commands/health/windows.js +1 -1
  29. package/dist/commands/health.js +1 -1
  30. package/dist/commands/improve/anti-collapse.js +3 -3
  31. package/dist/commands/improve/collapse-detector.js +5 -5
  32. package/dist/commands/improve/consolidate.js +123 -66
  33. package/dist/commands/improve/distill/promote-memory.js +8 -6
  34. package/dist/commands/improve/distill/quality-gate.js +1 -1
  35. package/dist/commands/improve/distill-guards.js +1 -1
  36. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  37. package/dist/commands/improve/distill.js +52 -36
  38. package/dist/commands/improve/eligibility.js +9 -8
  39. package/dist/commands/improve/extract-prompt.js +2 -2
  40. package/dist/commands/improve/extract.js +43 -11
  41. package/dist/commands/improve/improve-auto-accept.js +14 -28
  42. package/dist/commands/improve/improve-cli.js +4 -2
  43. package/dist/commands/improve/improve-strategies.js +2 -1
  44. package/dist/commands/improve/improve.js +49 -8
  45. package/dist/commands/improve/loop-stages.js +11 -6
  46. package/dist/commands/improve/preparation.js +51 -17
  47. package/dist/commands/improve/procedural.js +10 -6
  48. package/dist/commands/improve/recombine.js +31 -7
  49. package/dist/commands/improve/reflect.js +24 -14
  50. package/dist/commands/improve/salience.js +5 -1
  51. package/dist/commands/improve/source-identity.js +43 -0
  52. package/dist/commands/migrate-cli.js +36 -0
  53. package/dist/commands/mv-cli.js +647 -258
  54. package/dist/commands/proposal/drain.js +16 -4
  55. package/dist/commands/proposal/proposal-cli.js +3 -2
  56. package/dist/commands/proposal/proposal.js +16 -55
  57. package/dist/commands/proposal/repository.js +664 -58
  58. package/dist/commands/read/curate.js +4 -2
  59. package/dist/commands/read/knowledge.js +4 -1
  60. package/dist/commands/read/search.js +6 -2
  61. package/dist/commands/read/show.js +8 -7
  62. package/dist/commands/sources/self-update.js +156 -112
  63. package/dist/commands/sources/sources-cli.js +7 -2
  64. package/dist/commands/tasks/default-tasks.js +5 -5
  65. package/dist/commands/tasks/tasks-cli.js +7 -3
  66. package/dist/commands/tasks/tasks.js +261 -68
  67. package/dist/core/common.js +36 -3
  68. package/dist/core/config/config-io.js +2 -2
  69. package/dist/core/config/config-schema.js +15 -3
  70. package/dist/core/config/config.js +14 -13
  71. package/dist/core/file-lock.js +2 -2
  72. package/dist/core/improve-result.js +87 -7
  73. package/dist/core/migration-backup.js +816 -171
  74. package/dist/core/migration-operation.js +44 -0
  75. package/dist/core/state/migrations.js +4 -7
  76. package/dist/core/state-db.js +23 -7
  77. package/dist/core/write-source.js +86 -18
  78. package/dist/indexer/db/db.js +109 -53
  79. package/dist/indexer/index-writer-lock.js +38 -37
  80. package/dist/indexer/index-written-assets.js +73 -56
  81. package/dist/indexer/indexer.js +5 -1
  82. package/dist/indexer/search/search-source.js +2 -2
  83. package/dist/indexer/usage/usage-events.js +8 -2
  84. package/dist/integrations/agent/engine-resolution.js +14 -7
  85. package/dist/scripts/migrate-storage.js +887 -992
  86. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
  87. package/dist/setup/detected-engines.js +7 -13
  88. package/dist/setup/engine-config.js +14 -4
  89. package/dist/setup/setup.js +1 -1
  90. package/dist/setup/steps/connection.js +1 -1
  91. package/dist/setup/steps/tasks.js +1 -1
  92. package/dist/sources/providers/git-stash.js +58 -21
  93. package/dist/sources/providers/git.js +1 -1
  94. package/dist/storage/engines/sqlite-migrations.js +138 -3
  95. package/dist/storage/repositories/events-repository.js +24 -0
  96. package/dist/storage/repositories/proposals-repository.js +14 -0
  97. package/dist/storage/repositories/task-history-repository.js +30 -3
  98. package/dist/tasks/backends/cron.js +71 -38
  99. package/dist/tasks/backends/exec-utils.js +55 -3
  100. package/dist/tasks/backends/launchd.js +241 -50
  101. package/dist/tasks/backends/schtasks.js +434 -59
  102. package/dist/tasks/command-executable.js +93 -0
  103. package/dist/tasks/embedded.js +2 -0
  104. package/dist/tasks/parser.js +142 -6
  105. package/dist/tasks/resolve-akm-bin.js +19 -4
  106. package/dist/tasks/runner.js +258 -93
  107. package/dist/tasks/schedule.js +108 -19
  108. package/dist/tasks/scheduler-invocation.js +86 -0
  109. package/dist/tasks/task-id.js +37 -0
  110. package/dist/workflows/db.js +24 -13
  111. package/dist/workflows/validator.js +24 -2
  112. package/docs/README.md +103 -0
  113. package/docs/migration/release-notes/0.9.0.md +28 -9
  114. package/docs/migration/v0.8-to-v0.9.md +186 -26
  115. package/package.json +2 -3
@@ -21,13 +21,14 @@
21
21
  */
22
22
  import { createHash } from "node:crypto";
23
23
  import fs from "node:fs";
24
+ import path from "node:path";
24
25
  import proceduralSystemPrompt from "../../assets/prompts/procedural-system.md" with { type: "text" };
25
26
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
26
27
  import { resolveStashDir } from "../../core/common.js";
27
28
  import { loadConfig } from "../../core/config/config.js";
28
29
  import { appendEvent } from "../../core/events.js";
29
30
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
30
- import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
31
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
31
32
  import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
32
33
  import { parseWorkflow } from "../../workflows/parser.js";
33
34
  import { createProposal, isProposalSkipped } from "../proposal/repository.js";
@@ -212,10 +213,11 @@ function kebab(s) {
212
213
  .replace(/^-|-$/g, "") || "step");
213
214
  }
214
215
  /** Build the exact workflow markdown the parser accepts. */
215
- export function assembleWorkflowMarkdown(doc) {
216
+ export function assembleWorkflowMarkdown(doc, xrefs = []) {
216
217
  const lines = [
217
218
  "---",
218
219
  `description: ${JSON.stringify(doc.description)}`,
220
+ ...(xrefs.length > 0 ? ["xrefs:", ...xrefs.map((ref) => ` - ${ref}`)] : []),
219
221
  "---",
220
222
  "",
221
223
  `# Workflow: ${doc.title}`,
@@ -268,7 +270,8 @@ export async function akmProcedural(opts) {
268
270
  let db;
269
271
  try {
270
272
  db = openExistingDatabase();
271
- entries = getAllEntries(db);
273
+ const selectedRoot = path.resolve(stashDir);
274
+ entries = getAllEntries(db).filter((entry) => path.resolve(entry.stashDir) === selectedRoot);
272
275
  }
273
276
  catch (e) {
274
277
  warnings.push(`procedural: failed to open index — ${String(e)}`);
@@ -301,7 +304,7 @@ export async function akmProcedural(opts) {
301
304
  }
302
305
  // Procedural output is a workflow (non-wiki) → stash authoring standards.
303
306
  // Resolved ONCE per run and passed to each sequence prompt.
304
- const standardsContext = resolveStashStandards(stashDir);
307
+ const standardsContext = resolveStandardsContext("workflow:_procedural", stashDir);
305
308
  for (const cluster of clusters) {
306
309
  if (opts.signal?.aborted) {
307
310
  warnings.push("aborted-mid-run");
@@ -345,7 +348,8 @@ export async function akmProcedural(opts) {
345
348
  }
346
349
  // Assemble + locally validate the workflow markdown. Never queue an
347
350
  // unparseable workflow.
348
- const content = assembleWorkflowMarkdown(doc);
351
+ const xrefs = cluster.members.map((member) => member.ref).sort();
352
+ const content = assembleWorkflowMarkdown(doc, xrefs);
349
353
  const parsed = parseWorkflow(content, { path: workflowRef });
350
354
  if (!parsed.ok) {
351
355
  appendEvent({
@@ -365,7 +369,7 @@ export async function akmProcedural(opts) {
365
369
  ref: workflowRef,
366
370
  source: "procedural",
367
371
  sourceRun,
368
- payload: { content, frontmatter: { description: doc.description } },
372
+ payload: { content, frontmatter: { description: doc.description, xrefs } },
369
373
  eligibilitySource,
370
374
  }, opts.ctx);
371
375
  if (isProposalSkipped(proposalResult)) {
@@ -25,8 +25,9 @@
25
25
  * not re-promoted on every subsequent run. Hypotheses NOT re-induced in a run
26
26
  * have their consecutive streak reset (decay-to-zero).
27
27
  *
28
- * NAMESPACE note: the ref stays `lesson:recombined/<slug>-<hash>` for BOTH
29
- * passes. The ref is the promotion TARGET asset (a lesson in both the hypothesis
28
+ * NAMESPACE note: the ref stays stable for BOTH passes. A unanimous source-memory
29
+ * scope is preserved; unscoped members produce a flat lesson ref rather than an
30
+ * invented type scope. The ref is the promotion TARGET asset (a lesson in both the hypothesis
30
31
  * and promoted states), so re-induction must map to the same ref and the ref
31
32
  * cannot encode the proposal type. The hypothesis-vs-lesson distinction is
32
33
  * carried ONLY by the proposal frontmatter `type` field. On promotion the prior
@@ -39,6 +40,7 @@
39
40
  */
40
41
  import { createHash } from "node:crypto";
41
42
  import fs from "node:fs";
43
+ import path from "node:path";
42
44
  import recombineSystemPrompt from "../../assets/prompts/recombine-system.md" with { type: "text" };
43
45
  import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
44
46
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
@@ -46,7 +48,7 @@ import { resolveStashDir } from "../../core/common.js";
46
48
  import { loadConfig } from "../../core/config/config.js";
47
49
  import { appendEvent } from "../../core/events.js";
48
50
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
49
- import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
51
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
50
52
  import { withStateDbAsync } from "../../core/state-db.js";
51
53
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
52
54
  import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, markRecombineHypothesisPromoted, recordRecombineInduction, } from "../../storage/repositories/recombine-repository.js";
@@ -427,7 +429,24 @@ export function deriveRecombineLessonRef(cluster) {
427
429
  .replace(/^-|-$/g, "");
428
430
  const memberKey = recombineMemberKey(cluster);
429
431
  const hash = createHash("sha256").update(memberKey, "utf8").digest("hex").slice(0, 8);
430
- return `lesson:recombined/${slug || "cluster"}-${hash}`;
432
+ const scopeResult = recombineClusterScope(cluster);
433
+ if (!scopeResult.ok)
434
+ throw new Error("Cannot derive a lesson ref for a cluster with mixed scopes.");
435
+ return `lesson:${scopeResult.scope ? `${scopeResult.scope}/` : ""}${slug || "cluster"}-${hash}`;
436
+ }
437
+ function recombineClusterScope(cluster) {
438
+ const scopes = new Set(cluster.members.map((member) => {
439
+ const parts = member.entry.name.split("/");
440
+ return parts.length > 1 ? parts[0] : "";
441
+ }));
442
+ if (scopes.size !== 1)
443
+ return { ok: false };
444
+ const scope = [...scopes][0]
445
+ .toLowerCase()
446
+ .replace(/[^a-z0-9-]+/g, "-")
447
+ .replace(/-+/g, "-")
448
+ .replace(/^-|-$/g, "");
449
+ return { ok: true, scope };
431
450
  }
432
451
  function validatePromotedLessonFrontmatter(ref, frontmatter) {
433
452
  const descCheck = isValidDescription(frontmatter.description, ref);
@@ -511,7 +530,8 @@ export async function akmRecombine(opts) {
511
530
  let db;
512
531
  try {
513
532
  db = openExistingDatabase();
514
- entries = getAllEntries(db, "memory");
533
+ const selectedRoot = path.resolve(stashDir);
534
+ entries = getAllEntries(db, "memory").filter((entry) => path.resolve(entry.stashDir) === selectedRoot);
515
535
  if (relatednessSource === "graph" || relatednessSource === "both") {
516
536
  try {
517
537
  entityByEntryId = getEntitiesByEntryIds(db, entries.map((e) => e.id));
@@ -565,7 +585,7 @@ export async function akmRecombine(opts) {
565
585
  const seenThisRun = new Set();
566
586
  // Recombine output is knowledge/lesson (non-wiki) → stash authoring
567
587
  // standards. Resolved ONCE per run and passed to each cluster prompt.
568
- const standardsContext = resolveStashStandards(stashDir);
588
+ const standardsContext = resolveStandardsContext("lesson:_recombined", stashDir);
569
589
  // #625 — open the confirmation-count store once per run via the ctx seam,
570
590
  // reusing a long-lived ctx.db handle when the caller provided one (mirrors
571
591
  // proposals.ts). Only handles WE opened are closed by the seam.
@@ -576,6 +596,10 @@ export async function akmRecombine(opts) {
576
596
  break;
577
597
  }
578
598
  clustersFormed += 1;
599
+ if (!recombineClusterScope(cluster).ok) {
600
+ warnings.push(`recombine: skipped ${cluster.signature} cluster with mixed scopes`);
601
+ continue;
602
+ }
579
603
  // #9 — promotion is terminal. If this cluster Jaccard-matches a
580
604
  // hypothesis that was ALREADY promoted, generating again can at best
581
605
  // re-queue a redundant `type: hypothesis` for a settled ref (promotion
@@ -709,7 +733,7 @@ export async function akmRecombine(opts) {
709
733
  type: proposalType,
710
734
  description: generalization.description,
711
735
  ...(generalization.when_to_use ? { when_to_use: generalization.when_to_use } : {}),
712
- source_refs: sourceRefs,
736
+ xrefs: sourceRefs,
713
737
  };
714
738
  const content = assembleContent(frontmatter, generalization.body);
715
739
  if (promote && stateDb) {
@@ -46,7 +46,9 @@ import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/
46
46
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/repository.js";
47
47
  import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
48
48
  import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
49
+ import { findAssetFilePath } from "./eligibility.js";
49
50
  import { classifyReflectChange } from "./reflect-noise.js";
51
+ import { bareImproveRef, durableImproveRef } from "./source-identity.js";
50
52
  const MAX_FEEDBACK_LINES = 10;
51
53
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
52
54
  /**
@@ -55,12 +57,13 @@ const MAX_GLOBAL_FEEDBACK_LINES = 20;
55
57
  * all assets so `akm reflect` can operate in a general "review recent
56
58
  * signals" mode. Best-effort — a missing or empty events stream returns `[]`.
57
59
  */
58
- function readRecentFeedback(ref) {
60
+ function readRecentFeedback(ref, legacyRef) {
59
61
  try {
60
- const result = readEvents({ type: "feedback", ...(ref ? { ref } : {}) });
62
+ const result = readEvents({ type: "feedback", ...(ref && !legacyRef ? { ref } : {}) });
63
+ const events = ref && legacyRef ? result.events.filter((event) => event.ref === ref || event.ref === legacyRef) : result.events;
61
64
  const lines = [];
62
65
  const limit = ref ? MAX_FEEDBACK_LINES : MAX_GLOBAL_FEEDBACK_LINES;
63
- for (const event of result.events.slice(-limit)) {
66
+ for (const event of events.slice(-limit)) {
64
67
  const md = (event.metadata ?? {});
65
68
  const signal = typeof md.signal === "string" ? md.signal : "?";
66
69
  const note = typeof md.reason === "string" ? md.reason : typeof md.note === "string" ? md.note : "";
@@ -187,7 +190,7 @@ function hasRelatedSkillSource(content, skillRef) {
187
190
  const sources = parsed.data.sources;
188
191
  return Array.isArray(sources) && sources.some((source) => typeof source === "string" && source.trim() === skillRef);
189
192
  }
190
- async function readRelatedLessons(stash, ref, parsedRef) {
193
+ async function readRelatedLessons(stash, ref, parsedRef, sourceName) {
191
194
  if (parsedRef.type !== "skill")
192
195
  return [];
193
196
  const related = new Map();
@@ -198,7 +201,7 @@ async function readRelatedLessons(stash, ref, parsedRef) {
198
201
  related.set(derivedLessonRef, { ref: derivedLessonRef, content: fs.readFileSync(derivedLessonPath, "utf8") });
199
202
  }
200
203
  try {
201
- const feedbackEvents = readEvents({ type: "distill_invoked", ref }).events;
204
+ const feedbackEvents = readEvents({ type: "distill_invoked", ref: durableImproveRef(ref, sourceName) }).events;
202
205
  for (const event of feedbackEvents) {
203
206
  const lessonRef = typeof event.metadata?.lessonRef === "string" ? event.metadata.lessonRef : undefined;
204
207
  if (lessonRef?.startsWith("lesson:"))
@@ -210,10 +213,10 @@ async function readRelatedLessons(stash, ref, parsedRef) {
210
213
  }
211
214
  for (const candidateRef of candidateRefs) {
212
215
  try {
213
- const entry = await lookup(parseAssetRef(candidateRef));
214
- if (!entry?.filePath || !fs.existsSync(entry.filePath))
216
+ const filePath = await findAssetFilePath(durableImproveRef(candidateRef, sourceName), stash);
217
+ if (!filePath || !fs.existsSync(filePath))
215
218
  continue;
216
- const content = fs.readFileSync(entry.filePath, "utf8");
219
+ const content = fs.readFileSync(filePath, "utf8");
217
220
  related.set(candidateRef, { ref: candidateRef, content });
218
221
  }
219
222
  catch {
@@ -698,7 +701,7 @@ export async function akmReflect(options = {}) {
698
701
  // attempt regardless of downstream success/failure.
699
702
  appendEvent({
700
703
  eventType: "reflect_invoked",
701
- ...(options.ref ? { ref: options.ref } : {}),
704
+ ...(options.ref ? { ref: durableImproveRef(options.ref, options.sourceName) } : {}),
702
705
  metadata: {
703
706
  ...(options.task ? { task: options.task } : {}),
704
707
  ...(options.engine ? { engine: options.engine } : {}),
@@ -760,9 +763,16 @@ export async function akmReflect(options = {}) {
760
763
  }
761
764
  else {
762
765
  try {
763
- const entry = await lookup(parsedRef);
764
- if (entry?.filePath && fs.existsSync(entry.filePath)) {
765
- assetContent = fs.readFileSync(entry.filePath, "utf8");
766
+ const qualifiedRef = durableImproveRef(options.ref, options.sourceName);
767
+ const localFilePath = await findAssetFilePath(qualifiedRef, stash);
768
+ if (localFilePath && fs.existsSync(localFilePath)) {
769
+ assetContent = fs.readFileSync(localFilePath, "utf8");
770
+ }
771
+ else {
772
+ const entry = await lookup(parseAssetRef(qualifiedRef));
773
+ if (entry?.filePath && fs.existsSync(entry.filePath)) {
774
+ assetContent = fs.readFileSync(entry.filePath, "utf8");
775
+ }
766
776
  }
767
777
  }
768
778
  catch {
@@ -809,9 +819,9 @@ export async function akmReflect(options = {}) {
809
819
  // 4. Build the shared prompt inputs — feedback, hints, lessons, rejected
810
820
  // proposals. These are stable across refinement iterations; only the
811
821
  // `priorDraft` field changes per-iteration (R-1 / #372).
812
- const feedback = readRecentFeedback(options.ref);
822
+ const feedback = readRecentFeedback(options.ref ? durableImproveRef(options.ref, options.sourceName) : undefined, options.ref && options.legacyBareState ? bareImproveRef(options.ref) : undefined);
813
823
  const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
814
- const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(stash, options.ref, parsedRef) : [];
824
+ const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(stash, options.ref, parsedRef, options.sourceName) : [];
815
825
  // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
816
826
  // reproducing proposals that have already been reviewed and refused.
817
827
  const rejectedProposals = readRejectedProposals(stash, options.ref);
@@ -45,6 +45,7 @@
45
45
  *
46
46
  * @module salience
47
47
  */
48
+ import path from "node:path";
48
49
  import { makeAssetRef } from "../../core/asset/asset-ref.js";
49
50
  import { getAllEntries, getUtilityScoresByIds } from "../../indexer/db/db.js";
50
51
  import { WARM_START_CAP } from "./outcome-loop.js";
@@ -424,14 +425,17 @@ export function buildRankChangeReport(oldRanks, newRanks, oldTopN = 200, forgett
424
425
  * @param indexDb - An open read-capable index database connection.
425
426
  * @param refs - The set of asset refs to look up.
426
427
  */
427
- export function getLastUseMsByRef(indexDb, refs) {
428
+ export function getLastUseMsByRef(indexDb, refs, stashDir) {
428
429
  const result = new Map();
429
430
  if (refs.length === 0)
430
431
  return result;
431
432
  const refSet = new Set(refs);
432
433
  const allEntries = getAllEntries(indexDb);
434
+ const selectedRoot = stashDir ? path.resolve(stashDir) : undefined;
433
435
  const idToRef = new Map();
434
436
  for (const indexed of allEntries) {
437
+ if (selectedRoot && path.resolve(indexed.stashDir) !== selectedRoot)
438
+ continue;
435
439
  const ref = makeAssetRef(indexed.entry.type, indexed.entry.name);
436
440
  if (refSet.has(ref))
437
441
  idToRef.set(indexed.id, ref);
@@ -0,0 +1,43 @@
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
+ import path from "node:path";
5
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
6
+ /** Key durable improve state by source without changing filesystem-facing refs. */
7
+ export function durableImproveRef(ref, sourceName) {
8
+ if (!sourceName)
9
+ return ref;
10
+ const parsed = parseAssetRef(ref);
11
+ return refToString({ ...parsed, origin: parsed.origin ?? sourceName });
12
+ }
13
+ /** Remove a durable source origin before filesystem/index lookups. */
14
+ export function bareImproveRef(ref) {
15
+ const parsed = parseAssetRef(ref);
16
+ return refToString({ type: parsed.type, name: parsed.name });
17
+ }
18
+ /** Qualified key first, with the pre-cutover bare key as an optional fallback. */
19
+ export function improveStateReadRefs(ref, sourceName, includeLegacyBare = false) {
20
+ const durable = durableImproveRef(ref, sourceName);
21
+ const bare = bareImproveRef(ref);
22
+ return includeLegacyBare && durable !== bare ? [durable, bare] : [durable];
23
+ }
24
+ /**
25
+ * Bare improve state predates source-qualified refs and belongs only to the
26
+ * historical local stash. Named sources at any other root must never inherit it.
27
+ */
28
+ export function shouldReadLegacyBareImproveState(sourceName, sourcePath, config) {
29
+ if (!sourceName || !sourcePath)
30
+ return false;
31
+ if (config.stashDir)
32
+ return path.resolve(sourcePath) === path.resolve(config.stashDir);
33
+ if (sourceName !== "stash")
34
+ return false;
35
+ if (!config.defaultWriteTarget)
36
+ return true;
37
+ if (config.defaultWriteTarget !== "stash")
38
+ return false;
39
+ const configuredStash = config.sources?.find((source) => source.name === "stash");
40
+ return (configuredStash?.type === "filesystem" &&
41
+ typeof configuredStash.path === "string" &&
42
+ path.resolve(configuredStash.path) === path.resolve(sourcePath));
43
+ }
@@ -0,0 +1,36 @@
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
+ import { defineGroupCommand, defineJsonCommand } from "../cli/shared.js";
5
+ import { UsageError } from "../core/errors.js";
6
+ const configArg = {
7
+ type: "string",
8
+ description: "Complete operator-prepared current config; optional when the active config is current",
9
+ };
10
+ export const migrateCommand = defineGroupCommand({
11
+ meta: { name: "migrate", description: "Inspect or apply config and durable database migrations" },
12
+ subCommands: {
13
+ status: defineJsonCommand({
14
+ meta: { name: "status", description: "Read-only cross-artifact migration eligibility check" },
15
+ args: { config: configArg },
16
+ async run({ args }) {
17
+ const { runMigrationStatus } = await import("../cli/config-migrate.js");
18
+ await runMigrationStatus({ preparedConfigPath: args.config });
19
+ },
20
+ }),
21
+ apply: defineJsonCommand({
22
+ meta: { name: "apply", description: "Create a verified backup and atomically apply pending migrations" },
23
+ args: {
24
+ config: configArg,
25
+ dryRun: { type: "boolean", default: false, description: "Run the same eligibility checks without mutation" },
26
+ },
27
+ async run({ args }) {
28
+ const { runMigrationApply } = await import("../cli/config-migrate.js");
29
+ await runMigrationApply({ preparedConfigPath: args.config, dryRun: args.dryRun });
30
+ },
31
+ }),
32
+ },
33
+ defaultRun() {
34
+ throw new UsageError("Choose `migrate status` or `migrate apply`.", "MISSING_REQUIRED_ARGUMENT");
35
+ },
36
+ });