akm-cli 0.9.6 → 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 (31) hide show
  1. package/CHANGELOG.md +190 -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/improve/improve-cli.js +1 -1
  8. package/dist/commands/improve/improve-strategies.js +0 -4
  9. package/dist/commands/improve/memory/memory-improve.js +2 -1
  10. package/dist/commands/improve/preparation.js +1 -1
  11. package/dist/commands/improve/reflect.js +1 -1
  12. package/dist/commands/lint/base-linter.js +141 -18
  13. package/dist/commands/lint/index.js +17 -4
  14. package/dist/commands/read/curate.js +47 -0
  15. package/dist/commands/read/search-cli.js +24 -1
  16. package/dist/core/asset/asset-placement.js +13 -2
  17. package/dist/core/asset/frontmatter.js +116 -0
  18. package/dist/core/asset/memory-archive.js +97 -0
  19. package/dist/core/config/engine-semantics.js +0 -2
  20. package/dist/scripts/akm-migrate-node.js +13 -7
  21. package/dist/scripts/akm-migrate.js +13 -7
  22. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
  23. package/dist/storage/repositories/index-connection.js +45 -3
  24. package/dist/tasks/backends/cron.js +49 -9
  25. package/dist/tasks/resolve-akm-bin.js +17 -2
  26. package/dist/tasks/scheduler-invocation.js +8 -1
  27. package/dist/tasks/source/parse-task-source.js +23 -9
  28. package/docs/reference/cli.md +5 -1
  29. package/package.json +1 -1
  30. package/dist/assets/improve-strategies/frequent.json +0 -15
  31. package/dist/assets/improve-strategies/memory-focus.json +0 -15
@@ -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
  }
@@ -385,8 +385,8 @@ function assertFixTargetWritable(stashRoot, sources) {
385
385
  return;
386
386
  // Same error kind and code `write-source.ts#ensureWritable` raises for the
387
387
  // identical refusal, so a scripted caller classifies both the same way.
388
- throw new UsageError(`lint --fix: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` +
389
- "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");
390
390
  }
391
391
  /** True when the issue represents a file deletion that was successfully applied. */
392
392
  function isFileDeletion(issue) {
@@ -523,7 +523,9 @@ export function lintAssetFile(ctx, subdir) {
523
523
  */
524
524
  function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
525
525
  const fix = options.fix === true;
526
- 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)
527
529
  assertFixTargetWritable(stashRoot, sources);
528
530
  const fixed = [];
529
531
  const flagged = [];
@@ -627,7 +629,18 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
627
629
  try {
628
630
  issues = [
629
631
  ...fileIssues,
630
- ...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),
631
644
  ];
632
645
  }
633
646
  catch (e) {
@@ -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
@@ -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;
@@ -207,6 +207,122 @@ export function spliceFrontmatterLine(raw, line) {
207
207
  lines.splice(closeIdx, 0, line);
208
208
  return lines.join("\n");
209
209
  }
210
+ /**
211
+ * Strip one layer of matching quotes — frontmatter list items are often quoted
212
+ * refs. Written as an explicit char compare rather than a backreference regex
213
+ * on purpose: `scripts/lint-repository-sql.ts`'s comment/string stripper has no
214
+ * regex-literal awareness, so a literal holding an ODD number of quote
215
+ * characters desyncs its state machine and corrupts every match after it.
216
+ */
217
+ function unquote(value) {
218
+ const trimmed = value.trim();
219
+ if (trimmed.length < 2)
220
+ return trimmed;
221
+ const first = trimmed[0];
222
+ if ((first === '"' || first === "'") && trimmed[trimmed.length - 1] === first)
223
+ return trimmed.slice(1, -1);
224
+ return trimmed;
225
+ }
226
+ /**
227
+ * Remove specific VALUES from one frontmatter list key, preserving every other
228
+ * byte — the counterpart to {@link spliceFrontmatterLine} for the
229
+ * `akm lint --prune-dangling-edges` repair (#884).
230
+ *
231
+ * Handles the three spellings a belief channel appears in: a block sequence
232
+ * (`contradictedBy:\n - a`), an inline flow (`contradictedBy: [a, b]`), and a
233
+ * bare scalar (`contradictedBy: a`). When every value under the key is removed
234
+ * the key itself goes too — an empty `contradictedBy: []` is not the same
235
+ * assertion as no edge at all.
236
+ *
237
+ * Returns the rewritten source, or `null` when `raw` has no well-formed
238
+ * frontmatter block or nothing matched, so the caller can leave the file
239
+ * untouched and report the finding unfixed. Deliberately source-preserving:
240
+ * these are user-authored memories, and a repair must not silently reformat
241
+ * the frontmatter it was not asked to touch.
242
+ */
243
+ export function removeFrontmatterListValues(raw, key, values) {
244
+ const remove = new Set(values.map((v) => unquote(v)));
245
+ if (remove.size === 0)
246
+ return null;
247
+ const lines = raw.split(/\r?\n/);
248
+ if (lines[0]?.trim() !== "---")
249
+ return null;
250
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
251
+ if (closeIdx === -1)
252
+ return null;
253
+ const out = [];
254
+ let changed = false;
255
+ let index = 0;
256
+ while (index < lines.length) {
257
+ const line = lines[index];
258
+ if (index === 0 || index >= closeIdx) {
259
+ out.push(line);
260
+ index += 1;
261
+ continue;
262
+ }
263
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
264
+ if (kv === null || kv[1] !== key) {
265
+ out.push(line);
266
+ index += 1;
267
+ continue;
268
+ }
269
+ const rest = kv[2].trim();
270
+ // Inline flow: contradictedBy: [a, b]
271
+ const flow = rest.match(/^\[(.*)\]$/);
272
+ if (flow !== null) {
273
+ const kept = flow[1]
274
+ .split(",")
275
+ .map((item) => item.trim())
276
+ .filter(Boolean)
277
+ .filter((item) => !remove.has(unquote(item)));
278
+ const original = flow[1].split(",").filter((s) => s.trim().length > 0).length;
279
+ if (kept.length !== original) {
280
+ changed = true;
281
+ if (kept.length > 0)
282
+ out.push(`${key}: [${kept.join(", ")}]`);
283
+ }
284
+ else {
285
+ out.push(line);
286
+ }
287
+ index += 1;
288
+ continue;
289
+ }
290
+ // Bare scalar: contradictedBy: a
291
+ if (rest !== "") {
292
+ if (remove.has(unquote(rest)))
293
+ changed = true;
294
+ else
295
+ out.push(line);
296
+ index += 1;
297
+ continue;
298
+ }
299
+ // Block sequence: the key line, then ` - value` items.
300
+ const header = line;
301
+ const items = [];
302
+ let cursor = index + 1;
303
+ while (cursor < closeIdx) {
304
+ const itemMatch = lines[cursor].match(/^\s+-\s*(.*)$/);
305
+ if (itemMatch === null)
306
+ break;
307
+ items.push(lines[cursor]);
308
+ cursor += 1;
309
+ }
310
+ const kept = items.filter((item) => !remove.has(unquote(item.replace(/^\s*-\s*/, ""))));
311
+ if (kept.length !== items.length) {
312
+ changed = true;
313
+ if (kept.length > 0) {
314
+ out.push(header);
315
+ out.push(...kept);
316
+ }
317
+ }
318
+ else {
319
+ out.push(header);
320
+ out.push(...items);
321
+ }
322
+ index = cursor;
323
+ }
324
+ return changed ? out.join("\n") : null;
325
+ }
210
326
  /**
211
327
  * Parse a YAML scalar value (string, boolean, or number).
212
328
  *
@@ -0,0 +1,97 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The memory-cleanup archive as a REF-RESOLUTION surface (#884).
6
+ *
7
+ * `analyzeMemoryCleanup`'s prune (`commands/improve/memory/memory-improve.ts`
8
+ * `#archiveMemory`) does not delete a memory: it `rename`s the file under
9
+ * `.akm/memory-cleanup/archive/<stamp>-<ref>/<originalPath>` and writes a
10
+ * sibling `cleanup.md` audit asset carrying `ref` / `originalPath` /
11
+ * `archivedPath`. The bytes and the identity both survive — but the ref stops
12
+ * resolving at its ORIGINAL location, so every inbound belief edge
13
+ * (`contradictedBy` / `supersededBy`) pointing at the pruned memory becomes a
14
+ * `missing-ref` the moment #882 made those channels validatable.
15
+ *
16
+ * That is the #884 defect, and the archive already holds everything needed to
17
+ * fix it: the audit record IS a tombstone. This module reads those tombstones
18
+ * so ref resolution can answer "archived" instead of "missing". Resolution
19
+ * stays non-destructive — pruning never rewrites an unrelated memory's
20
+ * frontmatter, and the contradiction an edge records is preserved rather than
21
+ * erased (the concern #884 raised against a bare edge-scrub).
22
+ *
23
+ * A ref whose target has NO tombstone and no file is genuinely dangling — it
24
+ * was removed by something other than prune (a hand `git rm`, an older
25
+ * release). Those stay reported; clearing them mutates user data and so is
26
+ * gated behind `akm lint --prune-dangling-edges`.
27
+ */
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { parseFrontmatter } from "./frontmatter.js";
31
+ /** Stash-relative root the prune path archives into. Must match `memory-improve.ts#createArchiveDir`. */
32
+ export const MEMORY_ARCHIVE_REL = ".akm/memory-cleanup/archive";
33
+ /** Filename of the per-archive audit asset written alongside the archived memory. */
34
+ const AUDIT_FILENAME = "cleanup.md";
35
+ /**
36
+ * Cache keyed by stash root. A lint sweep resolves thousands of refs against a
37
+ * directory that only the prune path ever writes, so the scan runs once per
38
+ * root instead of once per missing ref.
39
+ */
40
+ const cache = new Map();
41
+ /** @internal Drop the memoized scans — process-global state needs a reset seam for tests (#785). */
42
+ export function resetMemoryArchiveCache() {
43
+ cache.clear();
44
+ }
45
+ /**
46
+ * Every stash-relative `originalPath` archived under `root`, i.e. the set of
47
+ * paths that USED to hold a memory and now hold a tombstone instead.
48
+ *
49
+ * Unreadable or malformed audit records are skipped rather than thrown: a
50
+ * corrupt tombstone must degrade to "this ref is missing" (the pre-#884
51
+ * answer), never break the whole lint sweep.
52
+ */
53
+ export function archivedOriginalPaths(root) {
54
+ const cached = cache.get(root);
55
+ if (cached !== undefined)
56
+ return cached;
57
+ const paths = new Set();
58
+ const archiveRoot = path.join(root, MEMORY_ARCHIVE_REL);
59
+ let entries;
60
+ try {
61
+ entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
62
+ }
63
+ catch {
64
+ cache.set(root, paths); // no archive dir — nothing was ever pruned here
65
+ return paths;
66
+ }
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory())
69
+ continue;
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(path.join(archiveRoot, entry.name, AUDIT_FILENAME), "utf8");
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ let originalPath;
78
+ try {
79
+ originalPath = parseFrontmatter(raw).data.originalPath;
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (typeof originalPath === "string" && originalPath.trim().length > 0) {
85
+ paths.add(originalPath.trim().replace(/\\/g, "/"));
86
+ }
87
+ }
88
+ cache.set(root, paths);
89
+ return paths;
90
+ }
91
+ /**
92
+ * True when `relPath` (stash-relative, POSIX) names a memory that prune
93
+ * archived — the ref resolves to a tombstone rather than to nothing.
94
+ */
95
+ export function isArchivedRelPath(relPath, root) {
96
+ return archivedOriginalPaths(root).has(relPath.replace(/\\/g, "/"));
97
+ }
@@ -6,9 +6,7 @@ export const BUILTIN_IMPROVE_STRATEGY_NAMES = [
6
6
  "default",
7
7
  "quick",
8
8
  "thorough",
9
- "memory-focus",
10
9
  "graph-refresh",
11
- "frequent",
12
10
  "consolidate",
13
11
  "catchup",
14
12
  "reflect-distill",