akm-cli 0.9.5 → 0.9.7

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 (130) hide show
  1. package/CHANGELOG.md +295 -0
  2. package/dist/assets/hints/cli-hints-full.md +3 -3
  3. package/dist/assets/improve-strategies/catchup.json +40 -11
  4. package/dist/assets/improve-strategies/thorough.json +45 -7
  5. package/dist/assets/tasks/improve/akm-improve-frequent.yml +2 -2
  6. package/dist/commands/agent/contribute-cli.js +11 -0
  7. package/dist/commands/env/env-cli.js +1 -2
  8. package/dist/commands/env/secret-cli.js +1 -5
  9. package/dist/commands/feedback-cli.js +0 -4
  10. package/dist/commands/health/checks.js +0 -32
  11. package/dist/commands/health/surfaces.js +2 -2
  12. package/dist/commands/health.js +6 -15
  13. package/dist/commands/improve/autonomy-gate.js +1 -1
  14. package/dist/commands/improve/consolidate.js +25 -26
  15. package/dist/commands/improve/distill.js +2 -2
  16. package/dist/commands/improve/extract.js +8 -1
  17. package/dist/commands/improve/improve-cli.js +1 -1
  18. package/dist/commands/improve/improve-strategies.js +0 -4
  19. package/dist/commands/improve/improve.js +3 -5
  20. package/dist/commands/improve/locks.js +13 -5
  21. package/dist/commands/improve/loop-stages.js +8 -9
  22. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  23. package/dist/commands/improve/memory/memory-improve.js +2 -1
  24. package/dist/commands/improve/outcome-loop.js +0 -6
  25. package/dist/commands/improve/preparation.js +1 -1
  26. package/dist/commands/improve/reflect.js +21 -10
  27. package/dist/commands/lint/base-linter.js +141 -18
  28. package/dist/commands/lint/index.js +21 -10
  29. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  30. package/dist/commands/read/curate.js +47 -0
  31. package/dist/commands/read/search-cli.js +24 -1
  32. package/dist/commands/sources/bundle-config-ops.js +9 -4
  33. package/dist/commands/sources/info.js +14 -10
  34. package/dist/commands/tasks/tasks.js +12 -16
  35. package/dist/commands/workflow-cli.js +2 -2
  36. package/dist/core/asset/asset-placement.js +13 -2
  37. package/dist/core/asset/frontmatter.js +116 -0
  38. package/dist/core/asset/memory-archive.js +97 -0
  39. package/dist/core/common.js +61 -24
  40. package/dist/core/config/config-io.js +2 -2
  41. package/dist/core/config/config-sources.js +32 -2
  42. package/dist/core/config/config.js +1 -1
  43. package/dist/core/config/engine-semantics.js +0 -2
  44. package/dist/core/config/schema/engines.js +9 -7
  45. package/dist/core/config/schema/primitives.js +0 -5
  46. package/dist/core/config/schema/search.js +2 -1
  47. package/dist/core/file-lock.js +2 -1
  48. package/dist/core/maintenance-barrier.js +2 -14
  49. package/dist/core/paths.js +0 -3
  50. package/dist/core/redaction.js +2 -2
  51. package/dist/core/spawn-env.js +8 -12
  52. package/dist/core/state/migrations.js +1 -12
  53. package/dist/core/state-db.js +9 -27
  54. package/dist/core/write-source.js +12 -19
  55. package/dist/execution/directory-identity.js +36 -10
  56. package/dist/indexer/graph/graph-boost.js +0 -4
  57. package/dist/indexer/index-writer-lock.js +43 -24
  58. package/dist/indexer/index-written-assets.js +5 -6
  59. package/dist/indexer/indexer.js +2 -39
  60. package/dist/indexer/materialize-embeddings.js +85 -41
  61. package/dist/indexer/search/db-search.js +15 -48
  62. package/dist/indexer/search/ranking-contributors.js +0 -25
  63. package/dist/indexer/search/ranking.js +3 -13
  64. package/dist/integrations/agent/builder-shared.js +0 -25
  65. package/dist/integrations/agent/model-map.js +2 -60
  66. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  67. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  68. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  69. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  70. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  71. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  72. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  74. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  75. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  76. package/dist/integrations/session-logs/index.js +0 -9
  77. package/dist/llm/client.js +75 -42
  78. package/dist/llm/embedder.js +7 -3
  79. package/dist/llm/embedders/remote.js +141 -42
  80. package/dist/registry/network.js +5 -37
  81. package/dist/runtime.js +2 -10
  82. package/dist/scripts/akm-migrate-node.js +73 -133
  83. package/dist/scripts/akm-migrate.js +73 -133
  84. package/dist/setup/engine-config.js +2 -5
  85. package/dist/setup/registry-stash-loader.js +0 -8
  86. package/dist/setup/setup.js +9 -46
  87. package/dist/setup/steps/connection-shared.js +10 -13
  88. package/dist/sources/providers/git-install.js +1 -1
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
  90. package/dist/storage/engines/sqlite-migrations.js +20 -1
  91. package/dist/storage/repositories/index-connection.js +45 -3
  92. package/dist/storage/repositories/index-entries-repository.js +0 -15
  93. package/dist/tasks/backends/cron.js +49 -9
  94. package/dist/tasks/backends/launchd.js +15 -20
  95. package/dist/tasks/backends/schtasks.js +18 -8
  96. package/dist/tasks/resolve-akm-bin.js +17 -2
  97. package/dist/tasks/run/run-native-task.js +8 -6
  98. package/dist/tasks/scheduler-invocation.js +8 -1
  99. package/dist/tasks/source/bounded-document.js +2 -4
  100. package/dist/tasks/source/parse-task-source.js +23 -9
  101. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  102. package/dist/tasks/source/task-source-v4.js +5 -10
  103. package/dist/tasks/source/task-to-v3.js +5 -10
  104. package/dist/tasks/source/task-to-v4.js +1 -4
  105. package/dist/tasks/source-v3.js +6 -6
  106. package/dist/workflows/exec/native-executor.js +21 -31
  107. package/dist/workflows/exec/run-workflow.js +5 -6
  108. package/dist/workflows/exec/scheduler.js +3 -19
  109. package/dist/workflows/exec/step-work.js +1 -4
  110. package/dist/workflows/exec/unit-dispatch.js +2 -2
  111. package/dist/workflows/exec/worktree.js +1 -13
  112. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  113. package/dist/workflows/ir/plan-hash.js +4 -6
  114. package/dist/workflows/ir/schema-v4.js +0 -12
  115. package/dist/workflows/ir/schema.js +20 -31
  116. package/dist/workflows/parser.js +11 -52
  117. package/dist/workflows/renderer.js +2 -3
  118. package/dist/workflows/resource-limits.js +11 -41
  119. package/dist/workflows/runtime/runs.js +3 -4
  120. package/dist/workflows/source-ir/schema.js +14 -30
  121. package/dist/workflows/validator.js +1 -7
  122. package/docs/reference/cli.md +5 -1
  123. package/docs/reference/configuration.md +1 -1
  124. package/package.json +1 -1
  125. package/schemas/akm-config.json +4 -23
  126. package/schemas/akm-task.json +1 -2
  127. package/schemas/akm-workflow.json +1 -13
  128. package/dist/assets/improve-strategies/frequent.json +0 -15
  129. package/dist/assets/improve-strategies/memory-focus.json +0 -15
  130. package/dist/indexer/search/semantic-status.js +0 -142
@@ -34,10 +34,11 @@
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
36
  import { isScalar, parseDocument } from "yaml";
37
- import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
37
+ import { assetPathCandidatesForName, assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
38
38
  import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
39
- import { spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
39
+ import { removeFrontmatterListValues, spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
40
40
  import { checkUnquotedDescriptionColon } from "../../core/asset/frontmatter-lint.js";
41
+ import { isArchivedRelPath } from "../../core/asset/memory-archive.js";
41
42
  import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
42
43
  import { localDateStamp } from "../../core/common.js";
43
44
  import { findFenceRegions } from "./markdown-insertion.js";
@@ -191,6 +192,37 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
191
192
  if (resolveRefPathInStash(relPath, refType, refName, root) !== null)
192
193
  return true;
193
194
  }
195
+ // #884: a memory pruned by `analyzeMemoryCleanup` was ARCHIVED, not deleted —
196
+ // its bytes and identity live on under `.akm/memory-cleanup/archive`. Inbound
197
+ // belief edges to it are satisfied, not dangling, so resolve the tombstone
198
+ // rather than reporting `missing-ref`. Checked only after every live location
199
+ // misses: a tombstone must never shadow a real file, and the scan then costs
200
+ // one directory read per root instead of one per ref.
201
+ //
202
+ // Existence ONLY. `resolveRefPathInStash` deliberately does NOT consult the
203
+ // archive: it hands back a path callers MUTATE (SPEC-5 `--supersedes`
204
+ // demotion), and writing into an archived tombstone would corrupt the audit
205
+ // record while leaving the live stash untouched.
206
+ return memoryArchiveHasRef(refType, refName, stashRoots);
207
+ }
208
+ /**
209
+ * True when `(refType, refName)` names a memory that prune archived in any
210
+ * root. Mirrors `resolveRefPathInStash`'s candidate set so a ref that resolved
211
+ * through the `.derived.md` twin (#882) still resolves once archived.
212
+ */
213
+ function memoryArchiveHasRef(refType, refName, stashRoots) {
214
+ if (refType !== "memory")
215
+ return false; // only memories are ever archived
216
+ const typeDir = stashDirFor(refType);
217
+ if (typeDir === undefined)
218
+ return false;
219
+ const candidates = assetPathCandidatesForName(refType, typeDir, refName);
220
+ for (const root of stashRoots) {
221
+ for (const candidate of candidates) {
222
+ if (isArchivedRelPath(candidate, root))
223
+ return true;
224
+ }
225
+ }
194
226
  return false;
195
227
  }
196
228
  /**
@@ -198,8 +230,9 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
198
230
  * the same reachability rules (in the same order) as
199
231
  * {@link refExistsInAnyStash}, which delegates here. Returns the absolute path
200
232
  * of the file that makes the ref "exist" — for a multi-file skill directory
201
- * that is its `SKILL.md` primary or `null` when the ref does not resolve in
202
- * this root.
233
+ * that is its `SKILL.md` primary, for a `memory` ref its `.derived.md` twin
234
+ * when the plain `.md` is absent (#882, see `assetPathCandidatesForName`) —
235
+ * or `null` when the ref does not resolve in this root.
203
236
  *
204
237
  * Extracted for SPEC-5 (`--supersedes` demotion): write commands need the
205
238
  * superseded asset's actual file to mutate, and forking a second resolver
@@ -207,10 +240,14 @@ export function refExistsInAnyStash(relPath, refType, refName, stashRoots) {
207
240
  * (the contract pins `refToRelPath` + `refExistsInAnyStash`; this is the
208
241
  * shared internal both build on).
209
242
  */
210
- export function resolveRefPathInStash(relPath, _refType, _refName, root) {
211
- const absPath = path.join(root, relPath);
212
- if (fs.existsSync(absPath))
213
- return absPath;
243
+ export function resolveRefPathInStash(relPath, refType, refName, root) {
244
+ const typeDir = stashDirFor(refType);
245
+ const candidates = typeDir === undefined ? [relPath] : assetPathCandidatesForName(refType, typeDir, refName);
246
+ for (const candidate of candidates) {
247
+ const absPath = path.join(root, candidate);
248
+ if (fs.existsSync(absPath))
249
+ return absPath;
250
+ }
214
251
  return null;
215
252
  }
216
253
  /**
@@ -281,11 +318,31 @@ function scanBundleRefs(scanBody, allRoots) {
281
318
  */
282
319
  function classifyConceptRef(rawConceptId, allRoots) {
283
320
  const conceptId = rawConceptId.split("#", 1)[0];
284
- const parts = typeNameFromConceptId(conceptId);
321
+ const parts = typeNameFromConceptId(conceptId) ?? legacyTypeSlugParts(conceptId);
285
322
  if (parts === undefined)
286
323
  return null; // foreign type / not a local asset ref
287
324
  return localRefMissingRelPath(parts.type, parts.name, allRoots);
288
325
  }
326
+ /**
327
+ * Read-only fallback for the retired `type:slug` xref grammar (see
328
+ * resolve-ref.ts Q-02 — the write boundary no longer emits or accepts it).
329
+ * Stash content written before that retirement still carries it in frontmatter
330
+ * `xrefs:` lists, and `typeNameFromConceptId` (conceptId-only) returns
331
+ * `undefined` for it, so without this it was silently skipped rather than
332
+ * validated (#882). `refToRelPath`/`refExistsInAnyStash` already key off a bare
333
+ * `(type, slug)` pair — the same shape `type:slug` already is — so this only
334
+ * needs to split the token; no new resolution logic.
335
+ */
336
+ function legacyTypeSlugParts(rawConceptId) {
337
+ const colon = rawConceptId.indexOf(":");
338
+ if (colon <= 0)
339
+ return undefined;
340
+ const type = rawConceptId.slice(0, colon);
341
+ const name = rawConceptId.slice(colon + 1);
342
+ if (!name || name.includes("/") || name.includes(":"))
343
+ return undefined;
344
+ return stashDirFor(type) === undefined ? undefined : { type, name };
345
+ }
289
346
  /**
290
347
  * Returns an array of {ref, resolvedRelPath} for every local AKM ref in the
291
348
  * PROSE body that does not resolve to a real file under any of the provided
@@ -364,6 +421,12 @@ function dedupeMissing(rows) {
364
421
  * excluded because it can point at merged-away or pruned assets.
365
422
  */
366
423
  const XREF_FRONTMATTER_KEYS = ["xrefs", "supersededBy", "contradictedBy"];
424
+ /**
425
+ * The belief-graph subset of {@link XREF_FRONTMATTER_KEYS} — the only channels
426
+ * `--prune-dangling-edges` will repair (#884). Typed as `readonly string[]` so
427
+ * `.includes` accepts any xref key without a cast.
428
+ */
429
+ const BELIEF_EDGE_KEYS = ["supersededBy", "contradictedBy"];
367
430
  /**
368
431
  * Return the `refs:` array from frontmatter when it is present and is an
369
432
  * array of strings; otherwise return `null` to signal the caller should
@@ -597,12 +660,22 @@ export function runBaseChecks(ctx) {
597
660
  });
598
661
  }
599
662
  }
600
- if (modified) {
601
- // Mirrors the two stub-delete call sites in `commands/lint/index.ts`
602
- // (`appendMemoryStubIssue`/`appendWorkflowStubIssue`), which already report
603
- // a failed mutation as `fixed: "failed"` instead of throwing. Without this
604
- // the sweep aborted mid-run on the first unwritable file and the caller got
605
- // an exception instead of a result naming the fixes that HAD landed.
663
+ /**
664
+ * Flush accumulated mutations to disk. Called after the fixable checks above
665
+ * AND again at the very end, because the `missing-ref` section below can also
666
+ * mutate (`--prune-dangling-edges`, #884) and used to run PAST this point
667
+ * its edits were computed and then silently dropped.
668
+ *
669
+ * Mirrors the two stub-delete call sites in `commands/lint/index.ts`
670
+ * (`appendMemoryStubIssue`/`appendWorkflowStubIssue`), which already report
671
+ * a failed mutation as `fixed: "failed"` instead of throwing. Without this
672
+ * the sweep aborted mid-run on the first unwritable file and the caller got
673
+ * an exception instead of a result naming the fixes that HAD landed.
674
+ */
675
+ const flushIfModified = () => {
676
+ if (!modified)
677
+ return;
678
+ modified = false;
606
679
  try {
607
680
  fs.writeFileSync(ctx.filePath, currentRaw, "utf8");
608
681
  // Propagate the mutated raw back so subclasses can re-parse if needed
@@ -615,7 +688,8 @@ export function runBaseChecks(ctx) {
615
688
  issue.detail = `${issue.detail} — could not write fix: ${reason}`;
616
689
  }
617
690
  }
618
- }
691
+ };
692
+ flushIfModified();
619
693
  // ── 3. stale-path ──────────────────────────────────────────────────────
620
694
  // M3: checkStalePath returns all stale matches; push one issue per path.
621
695
  // M4: Also scan ctx.frontmatter for stale paths (absolute paths in frontmatter).
@@ -636,6 +710,26 @@ export function runBaseChecks(ctx) {
636
710
  }
637
711
  }
638
712
  // ── 4. missing-ref ─────────────────────────────────────────────────────
713
+ const missingRefPass = runMissingRefChecks(ctx, currentRaw, shouldRun, pendingFixes);
714
+ issues.push(...missingRefPass.issues);
715
+ if (missingRefPass.modified) {
716
+ currentRaw = missingRefPass.raw;
717
+ modified = true;
718
+ }
719
+ flushIfModified();
720
+ return issues;
721
+ }
722
+ /**
723
+ * The `missing-ref` pass, extracted from {@link runBaseChecks} so that function
724
+ * stays under the src fn-size ratchet after #884 gave this pass its own
725
+ * (opt-in) mutation path. Pure move plus the explicit state hand-off: `raw` in,
726
+ * `{raw, modified}` out, `issues`/`pendingFixes` appended in place.
727
+ */
728
+ function runMissingRefChecks(ctx, raw, shouldRun, pendingFixes) {
729
+ const issues = [];
730
+ let currentRaw = raw;
731
+ let modified = false;
732
+ // ── 4. missing-ref ─────────────────────────────────────────────────────
639
733
  // Carve-out for assets that declare an explicit `refs:` array in
640
734
  // frontmatter (e.g. session-checkpoint memories captured by the
641
735
  // claude-code hook). The frontmatter array is the *authoritative*
@@ -650,7 +744,9 @@ export function runBaseChecks(ctx) {
650
744
  // still run `checkMissingRefs` against the array itself to catch
651
745
  // refs that were valid at capture time but later removed from the
652
746
  // stash.
653
- if (shouldRun("missing-ref")) {
747
+ if (!shouldRun("missing-ref"))
748
+ return { issues, raw, modified };
749
+ {
654
750
  const explicitRefs = extractFrontmatterRefs(ctx.data, ctx.body);
655
751
  // An explicit `refs:` array is a REF LIST (each value is a whole ref —
656
752
  // short conceptIds included); a bare body is PROSE (anchored refs only).
@@ -691,6 +787,33 @@ export function runBaseChecks(ctx) {
691
787
  if (values === null)
692
788
  continue;
693
789
  const missingXrefs = checkMissingRefsInList(values, ctx.stashRoot, ctx.extraStashRoots);
790
+ // #884 opt-in repair. Scoped to the BELIEF channels only: an edge whose
791
+ // target has neither a file nor a prune tombstone asserts a
792
+ // relationship to a memory that no longer exists in any form, and
793
+ // carrying it forward corrupts every belief-graph read. `xrefs` is
794
+ // excluded — a stale xref is an ordinary broken link (the 3 skill refs
795
+ // in #884), and repairing it by DELETION would throw away a pointer the
796
+ // author may simply need to re-target.
797
+ const repairable = ctx.pruneDanglingEdges === true && BELIEF_EDGE_KEYS.includes(key) && missingXrefs.length > 0;
798
+ if (repairable) {
799
+ const dropped = missingXrefs.map(({ ref }) => ref);
800
+ const rewritten = removeFrontmatterListValues(currentRaw, key, dropped);
801
+ if (rewritten !== null) {
802
+ currentRaw = rewritten;
803
+ modified = true;
804
+ for (const { ref, resolvedRelPath } of missingXrefs) {
805
+ const issue = {
806
+ file: ctx.relPath,
807
+ issue: "missing-ref",
808
+ detail: `dangling ${key} edge dropped: ${ref} (no file and no prune tombstone at ${resolvedRelPath})`,
809
+ fixed: true,
810
+ };
811
+ issues.push(issue);
812
+ pendingFixes.push(issue);
813
+ }
814
+ continue;
815
+ }
816
+ }
694
817
  for (const { ref, resolvedRelPath } of missingXrefs) {
695
818
  issues.push({
696
819
  file: ctx.relPath,
@@ -702,5 +825,5 @@ export function runBaseChecks(ctx) {
702
825
  }
703
826
  }
704
827
  }
705
- return issues;
828
+ return { issues, raw: currentRaw, modified };
706
829
  }
@@ -92,7 +92,7 @@ function collectWorkflowFiles(dir) {
92
92
  continue;
93
93
  const full = path.join(dir, entry.name);
94
94
  if (entry.isDirectory()) {
95
- if (entry.name === ".cache" || entry.name === "registry")
95
+ if (isAkmRegistryCachePath(full))
96
96
  continue;
97
97
  results.push(...collectWorkflowFiles(full));
98
98
  continue;
@@ -185,11 +185,9 @@ function collectAdapterFiles(root, extensions) {
185
185
  walk(full);
186
186
  }
187
187
  else if (entry.isFile() && matchesAdapterExtension(entry.name, extensions)) {
188
- // Compare PATH SEGMENTS, not raw substrings: `path.join` yields `\` on
189
- // Windows so a `"/.cache/"` substring test never matches there, and a
190
- // substring test would also skip a legitimately-named `registry` file.
191
- const segments = path.relative(root, full).split(/[\\/]/);
192
- if (segments.includes(".cache") || segments.includes("registry"))
188
+ // Skip akm's OWN resolved registry-cache copies, not any user
189
+ // directory that happens to be named ".cache" or "registry".
190
+ if (isAkmRegistryCachePath(full))
193
191
  continue;
194
192
  results.push(full);
195
193
  }
@@ -387,8 +385,8 @@ function assertFixTargetWritable(stashRoot, sources) {
387
385
  return;
388
386
  // Same error kind and code `write-source.ts#ensureWritable` raises for the
389
387
  // identical refusal, so a scripted caller classifies both the same way.
390
- throw new UsageError(`lint --fix: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` +
391
- "Run `akm lint` without --fix to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE");
388
+ throw new UsageError(`lint: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` +
389
+ "Run `akm lint` without --fix / --prune-dangling-edges to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE");
392
390
  }
393
391
  /** True when the issue represents a file deletion that was successfully applied. */
394
392
  function isFileDeletion(issue) {
@@ -525,7 +523,9 @@ export function lintAssetFile(ctx, subdir) {
525
523
  */
526
524
  function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
527
525
  const fix = options.fix === true;
528
- if (fix)
526
+ // #884: the dangling-edge repair writes too, so it clears the same
527
+ // read-only gate `--fix` does.
528
+ if (fix || options.pruneDanglingEdges === true)
529
529
  assertFixTargetWritable(stashRoot, sources);
530
530
  const fixed = [];
531
531
  const flagged = [];
@@ -629,7 +629,18 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
629
629
  try {
630
630
  issues = [
631
631
  ...fileIssues,
632
- ...lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots }, subdir),
632
+ ...lintAssetFile({
633
+ filePath,
634
+ relPath,
635
+ raw,
636
+ data,
637
+ body,
638
+ frontmatter,
639
+ fix,
640
+ pruneDanglingEdges: options.pruneDanglingEdges === true,
641
+ stashRoot,
642
+ extraStashRoots,
643
+ }, subdir),
633
644
  ];
634
645
  }
635
646
  catch (e) {
@@ -196,13 +196,6 @@ export function hasHotCaptureMode(frontmatter) {
196
196
  * with 80-90% overlap legitimately compress to well under 50% of the larger.
197
197
  */
198
198
  export const MERGE_SHRINK_RATIO_MIN = 0.3;
199
- /**
200
- * Absolute floor (chars) for merged body. When sources are short (<~333 chars),
201
- * `MERGE_SHRINK_RATIO_MIN × largerBodyLen` falls below this and the absolute
202
- * floor dominates — prevents false positives on very terse memory pairs.
203
- * Matches the existing `promote_source_too_small` floor of 100 chars.
204
- */
205
- export const MERGE_ABSOLUTE_FLOOR_CHARS = 100;
206
199
  // ── Reflect size gate ────────────────────────────────────────────────────────
207
200
  /** Ratio lower-bound: proposed body must be at least this fraction of source. */
208
201
  export const REFLECT_SHRINK_RATIO_MIN = 0.5;
@@ -24,6 +24,7 @@ import { enqueueGraphExtraction, hasGraphData } from "../../indexer/db/graph-db.
24
24
  import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
25
25
  import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
26
26
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
27
+ import { estimateTokenCount } from "../../llm/embedders/remote.js";
27
28
  import { truncateDescription } from "../../output/shapes/helpers.js";
28
29
  import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
29
30
  import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
@@ -163,6 +164,52 @@ export async function curateSearchResults(query, result, limit, selectedType, ev
163
164
  ...(result.tip ? { tip: result.tip } : {}),
164
165
  };
165
166
  }
167
+ /**
168
+ * Pack a curate result's stash hits into a single token-budgeted blob:
169
+ * resolve each hit's content via the SAME path `akm show` uses
170
+ * (`akmShowUnified` — this also means a `ref#fragment` hit packs just the
171
+ * matched section), then greedily accumulate hits, in the ranking order
172
+ * `curateSearchResults` already produced, until the next hit would exceed
173
+ * `budgetTokens`.
174
+ *
175
+ * Registry hits are never packed — only `CuratedStashItem`s (locked
176
+ * contract, AGENTS.md: registry results stay separate/opt-in).
177
+ *
178
+ * Truncation policy: drop whole hits from the tail of the ranked list first.
179
+ * The only exception is a single high-rank hit that alone exceeds the
180
+ * budget — that one hit is truncated to fit rather than dropping everything.
181
+ */
182
+ export async function packCuratedHits(result, budgetTokens) {
183
+ const stashItems = result.items.filter((item) => item.source === "local");
184
+ const packed = [];
185
+ let used = 0;
186
+ for (const item of stashItems) {
187
+ let shown;
188
+ try {
189
+ shown = await akmShowUnified({ ref: item.ref, skipLogging: true });
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ const content = shown.content ?? shown.template ?? shown.prompt ?? "";
195
+ const tokens = estimateTokenCount(content);
196
+ if (used + tokens <= budgetTokens) {
197
+ packed.push({ ref: item.ref, tokens, content });
198
+ used += tokens;
199
+ continue;
200
+ }
201
+ if (packed.length === 0) {
202
+ const remaining = budgetTokens - used;
203
+ if (remaining > 0) {
204
+ const truncated = content.slice(0, remaining * 4);
205
+ packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated });
206
+ used += estimateTokenCount(truncated);
207
+ }
208
+ }
209
+ break;
210
+ }
211
+ return { query: result.query, budget: budgetTokens, tokens: used, items: packed };
212
+ }
166
213
  async function enrichCuratedStashHit(query, hit, supportRefs, selectedRefs, eventSource) {
167
214
  let shown;
168
215
  try {
@@ -20,7 +20,8 @@ import { parseMetaRef } from "../../core/asset/stash-meta.js";
20
20
  import { UsageError } from "../../core/errors.js";
21
21
  import { resolveUsageEventSource } from "../../indexer/usage/usage-events.js";
22
22
  import { getOutputMode } from "../../output/context.js";
23
- import { akmCurate } from "./curate.js";
23
+ import { deliverRendered } from "../../output/html-render.js";
24
+ import { akmCurate, packCuratedHits } from "./curate.js";
24
25
  import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./search.js";
25
26
  import { akmShowUnified } from "./show.js";
26
27
  /**
@@ -153,6 +154,15 @@ export const curateCommand = defineJsonCommand({
153
154
  },
154
155
  limit: { type: "string", description: "Maximum number of curated results", default: "4" },
155
156
  from: { type: "string", description: "Search source (local|registry|all)", default: "local" },
157
+ pack: {
158
+ type: "string",
159
+ description: "Pack the ranked stash hits' full content into a single token-budgeted blob instead of returning refs " +
160
+ "to follow up on individually — value is the max token budget, e.g. --pack 4000 (~4 chars/token, same " +
161
+ "estimator as embedding). Content is resolved the same way `akm show` resolves it, so a ref#fragment " +
162
+ "hit packs just that section. Registry hits (--from registry|all) are never packed. Not to be confused " +
163
+ "with a workflow asset's own `budget` field (a run-cost cap) — this is a context-size target for this " +
164
+ "one curate call.",
165
+ },
156
166
  // Declared as the POSITIVE name with `default: true` — see the
157
167
  // `project-context` comment on `searchCommand` above for why a flag NAME
158
168
  // must never start with `no-`.
@@ -175,6 +185,7 @@ export const curateCommand = defineJsonCommand({
175
185
  const source = parseSearchSource(args.from ?? "local");
176
186
  const skipLogging = args["track-usage"] === false;
177
187
  const outputMode = getOutputMode();
188
+ const packBudget = parsePositiveIntFlag(args.pack ?? undefined, "--pack");
178
189
  const curated = await akmCurate({
179
190
  query: args.query,
180
191
  type,
@@ -184,9 +195,21 @@ export const curateCommand = defineJsonCommand({
184
195
  eventSource: resolveUsageEventSource(),
185
196
  attributionProjection: outputMode.shape === "agent" ? "agent" : outputMode.detail,
186
197
  });
198
+ if (packBudget !== undefined) {
199
+ const packed = await packCuratedHits(curated, packBudget);
200
+ deliverRendered(outputMode.format === "text" ? formatCuratePackText(packed) : JSON.stringify(packed.items, null, 2), outputMode.outputPath);
201
+ return;
202
+ }
187
203
  output("curate", curated);
188
204
  },
189
205
  });
206
+ /** Human-readable rendering for `akm curate --pack`: concatenated content per hit under a `## <ref>` header. */
207
+ function formatCuratePackText(packed) {
208
+ if (packed.items.length === 0) {
209
+ return `No packed content for "${packed.query}" (budget ${packed.budget} tokens).`;
210
+ }
211
+ return packed.items.map((item) => `## ${item.ref}\n\n${item.content}`).join("\n\n");
212
+ }
190
213
  /**
191
214
  * Reject `--scope` (either spelling) on `akm show` (E-3). `--scope` was
192
215
  * removed in favor of `--filter` (R-047, guardrail 6 — no alias, must keep
@@ -13,20 +13,25 @@
13
13
  * keying rules across call sites.
14
14
  */
15
15
  import path from "node:path";
16
- import { primaryBundlePath } from "../../core/config/config.js";
16
+ import { bundleKeyForContentRoot, primaryBundlePath } from "../../core/config/config.js";
17
17
  import { deriveBundleId } from "../../indexer/installations.js";
18
18
  export { primaryBundlePath };
19
19
  /**
20
20
  * Upsert the primary filesystem bundle (`{ path, writable: true }`) and point
21
21
  * `defaultBundle` at it. Reuses the current default key when it already names a
22
- * filesystem bundle (so re-pointing the primary keeps a stable id); otherwise
23
- * derives a fresh slug-legal key from the path.
22
+ * filesystem bundle (so re-pointing the primary keeps a stable id). Otherwise,
23
+ * reuses whichever configured bundle ALREADY resolves to `stashDir`'s content
24
+ * root (issue #870 — `AKM_BUNDLE_DIR`/`--dir` pointed at a directory already
25
+ * configured under a different id must not mint a second bundle for it); only
26
+ * when no bundle owns that root yet is a fresh slug-legal key derived.
24
27
  */
25
28
  export function withPrimaryBundle(config, stashDir) {
26
29
  const bundles = { ...(config.bundles ?? {}) };
27
30
  let key = config.defaultBundle;
28
31
  if (!key || !(key in bundles) || typeof bundles[key]?.path !== "string") {
29
- key = deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
32
+ key =
33
+ bundleKeyForContentRoot(config, path.resolve(stashDir)) ??
34
+ deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
30
35
  }
31
36
  bundles[key] = {
32
37
  ...bundles[key],
@@ -8,7 +8,6 @@ import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-ac
8
8
  import { getDbPath } from "../../core/paths.js";
9
9
  import { formatRegistryUrl } from "../../core/registry-url.js";
10
10
  import { error } from "../../core/warn.js";
11
- import { getEffectiveSemanticStatus, readSemanticStatus } from "../../indexer/search/semantic-status.js";
12
11
  import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
13
12
  import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
14
13
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -29,13 +28,6 @@ export function assembleInfo(options) {
29
28
  const defaultBundle = config.defaultBundle ?? null;
30
29
  // Asset types (copy into a mutable array — `placementTypes()` returns readonly)
31
30
  const assetTypes = [...placementTypes()];
32
- const semanticRuntime = readSemanticStatus();
33
- const semanticStatus = getEffectiveSemanticStatus(config, semanticRuntime);
34
- // Search modes
35
- const searchModes = ["fts"];
36
- if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
37
- searchModes.push("semantic", "hybrid");
38
- }
39
31
  // Registries (strip sensitive fields like apiKey from options)
40
32
  const registries = (config.registries ?? []).map((r) => ({
41
33
  url: formatRegistryUrl(r.url),
@@ -58,6 +50,20 @@ export function assembleInfo(options) {
58
50
  // health and search use, so info reads the same database they do.
59
51
  const resolvedDbPath = options?.dbPath ?? getDbPath();
60
52
  const indexStats = readIndexStats(resolvedDbPath);
53
+ // Semantic status is read live from the index's own state, not a cached
54
+ // verdict — a failed embed attempt at search time falls back to FTS and
55
+ // reports that in the search response, it never disables the mode here.
56
+ const semanticStatus = config.semanticSearchMode === "off"
57
+ ? "disabled"
58
+ : !indexStats.hasEmbeddings
59
+ ? "pending"
60
+ : indexStats.vecAvailable
61
+ ? "ready-vec"
62
+ : "ready-js";
63
+ const searchModes = ["fts"];
64
+ if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
65
+ searchModes.push("semantic", "hybrid");
66
+ }
61
67
  return {
62
68
  schemaVersion: 1,
63
69
  version: pkgVersion,
@@ -68,8 +74,6 @@ export function assembleInfo(options) {
68
74
  semanticSearch: {
69
75
  mode: config.semanticSearchMode,
70
76
  status: semanticStatus,
71
- ...(semanticRuntime?.reason ? { reason: semanticRuntime.reason } : {}),
72
- ...(semanticRuntime?.message ? { message: semanticRuntime.message } : {}),
73
77
  },
74
78
  registries,
75
79
  sourceProviders,
@@ -39,7 +39,6 @@ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot,
39
39
  import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
40
40
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
41
41
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
42
- import { TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
43
42
  import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
44
43
  import { applyAutonomyGate, configuredDirectAutonomyLanes, describeGatedLanes } from "../improve/autonomy-gate.js";
45
44
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
@@ -971,13 +970,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
971
970
  if (!rootStat.isDirectory()) {
972
971
  throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
973
972
  }
974
- const common = {
975
- filePath,
976
- rootRealPath,
977
- rootPhysicalIdentity: rootStat.ino === 0n ? `path:${rootRealPath}` : `inode:${rootStat.dev}:${rootStat.ino}`,
978
- rootMtimeNs: String(rootStat.mtimeNs),
979
- rootCtimeNs: String(rootStat.ctimeNs),
980
- };
973
+ const common = { filePath, rootRealPath };
981
974
  let descriptor;
982
975
  try {
983
976
  const noFollow = "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
@@ -986,9 +979,6 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
986
979
  if (!before.isFile()) {
987
980
  throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
988
981
  }
989
- if (before.size > BigInt(TASK_V3_MAX_SOURCE_BYTES)) {
990
- throw new UsageError(`${filePath} exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) task source limit.`, "INVALID_FLAG_VALUE");
991
- }
992
982
  const bytes = fs.readFileSync(descriptor);
993
983
  const after = fs.fstatSync(descriptor, { bigint: true });
994
984
  if (!sameTaskSourceStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
@@ -1010,10 +1000,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
1010
1000
  state: "present",
1011
1001
  ...common,
1012
1002
  realPath,
1013
- physicalIdentity: before.ino === 0n ? `path:${realPath}` : `inode:${before.dev}:${before.ino}`,
1014
1003
  size: bytes.byteLength,
1015
- mtimeNs: String(before.mtimeNs),
1016
- ctimeNs: String(before.ctimeNs),
1017
1004
  sha256: createHash("sha256").update(bytes).digest("hex"),
1018
1005
  bytesBase64: bytes.toString("base64"),
1019
1006
  content,
@@ -1035,6 +1022,11 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
1035
1022
  fs.closeSync(descriptor);
1036
1023
  }
1037
1024
  }
1025
+ // TOCTOU note: this compares CONTENT (state + sha256), not filesystem
1026
+ // identity (inode/mtime/ctime/directory timestamps) — the same split already
1027
+ // applied to the task migrator in 0.9.5. An unrelated touch to the file or
1028
+ // its containing directory must not trip a "changed after planning" refusal;
1029
+ // only a real content change should.
1038
1030
  function assertTaskSourceExpectation(expected) {
1039
1031
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1040
1032
  if (!sameTaskSourceExpectation(actual, expected)) {
@@ -1042,12 +1034,16 @@ function assertTaskSourceExpectation(expected) {
1042
1034
  }
1043
1035
  }
1044
1036
  function sameTaskSourceExpectation(left, right) {
1045
- return JSON.stringify(left) === JSON.stringify(right);
1037
+ if (left.state !== right.state || left.filePath !== right.filePath || left.rootRealPath !== right.rootRealPath) {
1038
+ return false;
1039
+ }
1040
+ if (left.state === "absent")
1041
+ return true;
1042
+ return left.sha256 === right.sha256;
1046
1043
  }
1047
1044
  function assertTaskSourceRestored(expected) {
1048
1045
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1049
1046
  const restored = actual.state === expected.state &&
1050
- actual.rootPhysicalIdentity === expected.rootPhysicalIdentity &&
1051
1047
  (actual.state === "absent" ||
1052
1048
  (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
1053
1049
  if (!restored) {
@@ -20,7 +20,7 @@ import { deliverRendered } from "../output/html-render.js";
20
20
  import { shapeForCommand } from "../output/shapes.js";
21
21
  import { formatPlain } from "../output/text.js";
22
22
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
23
- import { WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
23
+ import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
24
24
  import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
25
25
  import { akmWorkflowPlan } from "./workflow/plan.js";
26
26
  const workflowStatusCommand = defineJsonCommand({
@@ -168,7 +168,7 @@ const workflowRunCommand = defineJsonCommand({
168
168
  const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
169
169
  const parameterFlags = parseWorkflowParameterFlags(rawArgs, args.target);
170
170
  const maxSteps = parseIntegerFlag(getStringArg(args, "max-steps"), "--max-steps", 1);
171
- const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0, WORKFLOW_MAX_RETRIES);
171
+ const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0);
172
172
  const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
173
173
  const controller = new AbortController();
174
174
  let signalExitCode;
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
- import { SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
28
+ import { DERIVED_SUFFIX, SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
29
29
  function toPosix(input) {
30
30
  return input.replace(/\\/g, "/");
31
31
  }
@@ -247,10 +247,21 @@ export function assetPathForName(assetType, typeRoot, name) {
247
247
  * "default" alias is genuinely dual-owned: both `<dir>/.env` and
248
248
  * `<dir>/default.env` derive the same canonical name (`toCanonicalName`
249
249
  * above), so a physical-owner lookup must consider both without reading
250
- * either file. Every other placement type has exactly one inverse spelling.
250
+ * either file. `memory` has a second, analogous duality (#882): a ref to
251
+ * `<name>` may own either `<name>.md` or the LLM-inferred `<name>.derived.md`
252
+ * twin — `.derived` is a provenance marker on the SAME identity, not part of
253
+ * the name (see `resolveParentRef`/`isDerivedMemory` in
254
+ * `commands/improve/memory/derived-ref.ts`, and the belief-edge identity
255
+ * channel's own `memory:<name>.derived` refs). The plain `.md` file wins when
256
+ * both exist, so it stays `primary` — first in the returned list — and every
257
+ * caller here already prefers the first candidate that exists on disk. Every
258
+ * other placement type has exactly one inverse spelling.
251
259
  */
252
260
  export function assetPathCandidatesForName(assetType, typeRoot, name) {
253
261
  const primary = assetPathForName(assetType, typeRoot, name);
262
+ if (assetType === "memory" && !name.endsWith(DERIVED_SUFFIX)) {
263
+ return [primary, assetPathForName(assetType, typeRoot, `${name}${DERIVED_SUFFIX}`)];
264
+ }
254
265
  if (assetType !== "env")
255
266
  return [primary];
256
267
  const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;