akm-cli 0.9.7 → 0.9.8-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/data-dir-usage.js +165 -0
  5. package/dist/commands/health/improve-metrics.js +8 -34
  6. package/dist/commands/health/windows.js +0 -4
  7. package/dist/commands/health.js +18 -37
  8. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  9. package/dist/commands/improve/extract.js +36 -32
  10. package/dist/commands/improve/memory/memory-belief.js +15 -5
  11. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  12. package/dist/commands/improve/memory/memory-improve.js +7 -19
  13. package/dist/commands/improve/preparation.js +52 -36
  14. package/dist/commands/improve/reflect.js +13 -17
  15. package/dist/commands/lint/base-linter.js +42 -2
  16. package/dist/commands/lint/index.js +4 -5
  17. package/dist/commands/migrate/config-extra-params.js +61 -0
  18. package/dist/commands/migrate/dead-residue.js +113 -0
  19. package/dist/commands/migrate/stale-txn.js +49 -0
  20. package/dist/commands/migrate-cli.js +42 -1
  21. package/dist/commands/proposal/proposal.js +1 -21
  22. package/dist/commands/proposal/repository.js +0 -4
  23. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  24. package/dist/commands/read/curate.js +4 -18
  25. package/dist/commands/read/show.js +2 -1
  26. package/dist/commands/sources/installed-stashes.js +5 -1
  27. package/dist/commands/sources/self-update.js +38 -1
  28. package/dist/commands/sources/sources-cli.js +17 -1
  29. package/dist/commands/tasks/tasks.js +0 -8
  30. package/dist/commands/url-checker.js +20 -28
  31. package/dist/commands/workflow-cli.js +11 -61
  32. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  33. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  34. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  35. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  36. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  37. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  38. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  39. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  40. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  41. package/dist/core/adapter/adapters/shared.js +2 -0
  42. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  43. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  44. package/dist/core/adapter/validate-context.js +1 -3
  45. package/dist/core/asset/asset-placement.js +1 -3
  46. package/dist/core/asset/frontmatter.js +96 -0
  47. package/dist/core/common.js +66 -2
  48. package/dist/core/config/config-io.js +21 -8
  49. package/dist/core/config/config-walker.js +6 -10
  50. package/dist/core/config/config.js +11 -8
  51. package/dist/core/extra-params.js +17 -13
  52. package/dist/core/improve-result.js +1 -3
  53. package/dist/core/json-schema.js +9 -11
  54. package/dist/core/state/migrations.js +74 -2
  55. package/dist/core/state-db.js +2 -1
  56. package/dist/core/warn.js +15 -0
  57. package/dist/execution/executable-identity.js +1 -3
  58. package/dist/execution/guarded-source.js +1 -6
  59. package/dist/indexer/bundle-identity-guard.js +6 -1
  60. package/dist/indexer/db/graph-db.js +139 -154
  61. package/dist/indexer/ensure-index.js +11 -19
  62. package/dist/indexer/graph/graph-boost.js +23 -34
  63. package/dist/indexer/graph/graph-extraction.js +12 -2
  64. package/dist/indexer/indexer.js +43 -22
  65. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  66. package/dist/indexer/passes/dir-staleness.js +30 -9
  67. package/dist/indexer/passes/memory-inference.js +7 -2
  68. package/dist/indexer/scan/drain-dir.js +2 -4
  69. package/dist/indexer/search/db-search.js +3 -3
  70. package/dist/indexer/search/fts-query.js +10 -15
  71. package/dist/indexer/search/search-source.js +0 -13
  72. package/dist/indexer/usage/usage-events.js +9 -1
  73. package/dist/indexer/walk/walker.js +11 -6
  74. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  75. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  76. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  77. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  78. package/dist/integrations/harnesses/index.js +0 -4
  79. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  80. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  81. package/dist/integrations/lockfile.js +0 -14
  82. package/dist/integrations/session-logs/index.js +0 -81
  83. package/dist/llm/client.js +0 -12
  84. package/dist/llm/memory-infer.js +1 -3
  85. package/dist/llm/usage-telemetry.js +1 -3
  86. package/dist/registry/create-provider-registry.js +4 -0
  87. package/dist/registry/factory.js +4 -0
  88. package/dist/registry/resolve.js +23 -8
  89. package/dist/runtime.js +0 -12
  90. package/dist/scripts/akm-migrate-node.js +179 -248
  91. package/dist/scripts/akm-migrate.js +179 -248
  92. package/dist/setup/setup.js +6 -12
  93. package/dist/sources/providers/git-install.js +7 -2
  94. package/dist/sources/providers/tar-utils.js +1 -7
  95. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  96. package/dist/storage/managed-db.js +20 -7
  97. package/dist/storage/repositories/events-repository.js +0 -81
  98. package/dist/storage/repositories/index-entries-repository.js +24 -28
  99. package/dist/storage/repositories/index-fts-repository.js +24 -30
  100. package/dist/storage/repositories/index-meta-repository.js +6 -4
  101. package/dist/storage/repositories/index-schema.js +16 -1
  102. package/dist/storage/repositories/index-utility-repository.js +0 -57
  103. package/dist/storage/repositories/index-vec-repository.js +25 -27
  104. package/dist/storage/repositories/proposals-repository.js +4 -1
  105. package/dist/storage/repositories/task-history-repository.js +9 -3
  106. package/dist/tasks/backends/launchd.js +8 -18
  107. package/dist/tasks/resolve-akm-bin.js +15 -0
  108. package/dist/tasks/run/task-history.js +21 -31
  109. package/dist/tasks/scheduler-sync.js +1 -6
  110. package/dist/tasks/source/bounded-document.js +1 -14
  111. package/dist/tasks/source/task-to-v3.js +10 -2
  112. package/dist/workflows/exec/child-workflow.js +1 -1
  113. package/dist/workflows/exec/native-executor.js +2 -2
  114. package/dist/workflows/exec/step-work.js +5 -17
  115. package/dist/workflows/exec/worktree.js +40 -6
  116. package/dist/workflows/freeze/task-bindings.js +2 -4
  117. package/dist/workflows/ir/compile.js +3 -14
  118. package/dist/workflows/ir/schema-v4.js +4 -6
  119. package/dist/workflows/ir/schema.js +2 -5
  120. package/dist/workflows/parser.js +23 -26
  121. package/dist/workflows/source-files.js +8 -13
  122. package/dist/workflows/source-ir/schema.js +1 -19
  123. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
  125. package/docs/reference/cli.md +3 -1
  126. package/docs/reference/data-and-telemetry.md +1 -0
  127. package/docs/reference/tasks.md +11 -0
  128. package/docs/reference/workflow-schema.md +24 -0
  129. package/package.json +2 -9
  130. package/dist/workflows/source-ir/compare.js +0 -17
@@ -7,7 +7,6 @@ import { resolveStashDir } from "../core/common.js";
7
7
  import { loadConfig } from "../core/config/config.js";
8
8
  import { ConfigError, UsageError } from "../core/errors.js";
9
9
  import { readEvents } from "../core/events.js";
10
- import { listTxnJournalsTolerant, TXN_SWEEP_GRACE_MS } from "../core/fs-txn.js";
11
10
  import { openLogsDatabase } from "../core/logs-db.js";
12
11
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
12
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
@@ -19,6 +18,7 @@ import { queryTaskHistory } from "../storage/repositories/task-history-repositor
19
18
  import { pkgVersion } from "../version.js";
20
19
  import { collectImproveAdvisories } from "./health/advisories.js";
21
20
  import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
21
+ import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
22
22
  import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
23
23
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
24
24
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
@@ -150,37 +150,6 @@ function gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now) {
150
150
  agentFailureRate,
151
151
  };
152
152
  }
153
- /**
154
- * Item 4: leftover durable-transaction journals under `$DATA/txn` (stranded
155
- * recovery state seen twice in a real 0.9 migration) had zero health
156
- * visibility. Uses the tolerant scan variant so one corrupt journal.json is
157
- * counted rather than aborting the whole gather; a journal younger than
158
- * {@link TXN_SWEEP_GRACE_MS} is presumed to belong to a currently-running
159
- * operation and is not counted as stale. Best-effort: any unexpected error
160
- * (e.g. a permissions issue under `$DATA/txn`) degrades to "no signal" rather
161
- * than aborting the health report.
162
- */
163
- function gatherStaleTxnJournalsPhase(now) {
164
- const dir = path.join(getDataDir(), "txn");
165
- try {
166
- const { matches, unreadableMtimes } = listTxnJournalsTolerant(() => true);
167
- const nowMs = now();
168
- const staleAges = [
169
- ...matches.map((m) => nowMs - m.mtimeMs),
170
- ...unreadableMtimes.map((mtimeMs) => nowMs - mtimeMs),
171
- ].filter((ageMs) => ageMs >= TXN_SWEEP_GRACE_MS);
172
- const staleUnreadableCount = unreadableMtimes.filter((mtimeMs) => nowMs - mtimeMs >= TXN_SWEEP_GRACE_MS).length;
173
- return {
174
- dir,
175
- count: staleAges.length,
176
- oldestAgeMs: staleAges.length > 0 ? Math.max(...staleAges) : null,
177
- unreadable: staleUnreadableCount,
178
- };
179
- }
180
- catch {
181
- return { dir, count: 0, oldestAgeMs: null, unreadable: 0 };
182
- }
183
- }
184
153
  /**
185
154
  * Config fields the surfaces advisory needs. Best-effort: an unloadable
186
155
  * config leaves the field undefined and the caller falls back to a generic
@@ -229,9 +198,10 @@ function gatherImproveSummaryPhase(db, stateDbPath, since, now) {
229
198
  return { improveSummary, perRunSummaries };
230
199
  }
231
200
  /**
232
- * The four best-effort advisory groups beyond the health-check registry:
233
- * improve advisories, the `stash-git-exposure` probe, the 08 surfaces group
234
- * (binary-config-skew, egress-endpoints), and `plugin-version` (itlackey/akm#832).
201
+ * The best-effort advisory groups beyond the health-check registry: improve
202
+ * advisories, the `stash-git-exposure` probe, the 08 surfaces group
203
+ * (binary-config-skew, egress-endpoints), `type-directory-disagreement`
204
+ * (#831), `data-dir-usage` (#896), and `plugin-version` (itlackey/akm#832).
235
205
  * Order matches emission order in the returned array. A probe/filesystem
236
206
  * failure in any try/catch must not abort the health report — each group
237
207
  * degrades to "no advisory" independently.
@@ -283,6 +253,19 @@ function gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, optio
283
253
  catch {
284
254
  // Non-fatal.
285
255
  }
256
+ // #896: report the data dir's total size and its largest top-level
257
+ // subdirectory, so a disk-usage blowup (e.g. unpruned migration snapshot
258
+ // backups, #897) is self-diagnosing instead of requiring `du` archaeology.
259
+ // Best-effort — an unreadable/missing data dir must not abort the health
260
+ // report.
261
+ try {
262
+ const dataDirUsage = collectDataDirUsageAdvisory(getDataDir());
263
+ if (dataDirUsage)
264
+ advisories.push(dataDirUsage);
265
+ }
266
+ catch {
267
+ // Non-fatal.
268
+ }
286
269
  // itlackey/akm#832: report installed Claude Code harness plugin version(s)
287
270
  // and warn when stale or when the plugin's own akm-cli version range no
288
271
  // longer admits this CLI. Best-effort — no plugin installed, an unreadable
@@ -512,7 +495,6 @@ export function akmHealth(options = {}) {
512
495
  try {
513
496
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
514
497
  const { tableNames, missingTables, probe } = taskHistory;
515
- const staleTxnJournals = gatherStaleTxnJournalsPhase(now);
516
498
  const { egressConfigView } = gatherEgressConfigPhase();
517
499
  const { improveSummary } = gatherImproveSummaryPhase(db, stateDbPath, since, now);
518
500
  advisories.push(...gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView));
@@ -535,7 +517,6 @@ export function akmHealth(options = {}) {
535
517
  stuckActiveRuns: taskHistory.stuckActiveRuns,
536
518
  stuckActiveTasks: taskHistory.stuckActiveTasks,
537
519
  worstTaskFailRate: taskHistory.worstTaskFailRate,
538
- staleTxnJournals,
539
520
  sessionExtraction: improveSummary.sessionExtraction,
540
521
  autoAccept: improveSummary.autoAccept,
541
522
  engineProbes,
@@ -18,8 +18,8 @@ export function isConsolidationEligibleMemoryName(name) {
18
18
  *
19
19
  * Reads the file once per check; consolidate runs against ~10 memories per
20
20
  * chunk so the IO cost is trivial. Returns false on any read/parse error
21
- * (fail-safe: an unparseable file is treated as not-hot, but the broader
22
- * consolidate flow already guards against unparseable memories elsewhere).
21
+ * (fail-safe: an unreadable or unparseable file is treated as HOT protected
22
+ * because a deletion shield must not fail open; a missing file is not-hot).
23
23
  *
24
24
  * Defends against four observed defect classes (see
25
25
  * `memories/akm-improve-critical-review-2026-05-20`):
@@ -29,14 +29,20 @@ export function isConsolidationEligibleMemoryName(name) {
29
29
  * - Cascade deletes (LLM uses ref:X as `contradictedBy` for ref:Y then deletes both)
30
30
  */
31
31
  export function isHotCapturedMemory(filePath) {
32
+ if (!fs.existsSync(filePath))
33
+ return false;
32
34
  try {
33
- if (!fs.existsSync(filePath))
34
- return false;
35
35
  const content = fs.readFileSync(filePath, "utf8");
36
36
  const parsed = parseFrontmatter(content);
37
37
  return hasHotCaptureMode(parsed.data);
38
38
  }
39
39
  catch {
40
- return false;
40
+ // Fail CLOSED. This predicate is a deletion shield: "hot" memories are
41
+ // protected from consolidate's merge/delete. Returning false on a read or
42
+ // parse failure marked exactly the memories we could not inspect as fair
43
+ // game — the one direction a protection check must never fail. A missing
44
+ // file stays false (nothing to protect); an unreadable one is protected
45
+ // until someone can actually read it.
46
+ return true;
41
47
  }
42
48
  }
@@ -632,8 +632,40 @@ function unavailableExtractionResult(args) {
632
632
  ...args.notices,
633
633
  };
634
634
  }
635
+ // #561 — ADDITIVE session indexing. Generate + write the session asset
636
+ // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only returns a
637
+ // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
638
+ // frontmatter fields to merge into the per-session result for state-db
639
+ // correlation. When disabled this makes NO LLM call and writes NOTHING.
640
+ async function maybeWriteSessionAsset(runCtx, session) {
641
+ const { stashDir, lease, sessionIndexing, dryRun } = runCtx;
642
+ const { data } = session.gate;
643
+ if (!sessionIndexing.enabled || dryRun)
644
+ return {};
645
+ if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
646
+ return {};
647
+ try {
648
+ const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
649
+ if (result.written) {
650
+ // Write-path indexing (itself fail-open): standalone `akm extract`
651
+ // (session-end hook) has no post-loop reindex to pick this file up.
652
+ if (result.filePath)
653
+ await indexWrittenAssets(stashDir, [result.filePath]);
654
+ return {
655
+ ...(result.ref ? { sessionAssetRef: result.ref } : {}),
656
+ ...(result.logPath ? { sessionLogPath: result.logPath } : {}),
657
+ };
658
+ }
659
+ }
660
+ catch (err) {
661
+ if (err instanceof ConfigError)
662
+ throw err;
663
+ return { warning: `session asset write failed: ${err instanceof Error ? err.message : String(err)}` };
664
+ }
665
+ return {};
666
+ }
635
667
  async function processSession(runCtx, session) {
636
- const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, sessionIndexing, signal, standardsContext, } = runCtx;
668
+ const { harness, stashDir, config, llmRunner, lease, onNotices, getNotices, chat, ctx, eventsCtx, sourceRun, dryRun, timeoutMs, signal, standardsContext, } = runCtx;
637
669
  const { sessionRef, gate } = session;
638
670
  const warnings = [];
639
671
  const { data, filtered, contentHash } = gate;
@@ -645,36 +677,6 @@ async function processSession(runCtx, session) {
645
677
  inlineRefs: data.inlineRefs,
646
678
  ...(standardsContext.trim() ? { standardsContext } : {}),
647
679
  });
648
- // #561 — ADDITIVE session indexing. Generate + write the session asset
649
- // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
650
- // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
651
- // frontmatter fields to merge into the per-session result for state-db
652
- // correlation. When disabled this closure makes NO LLM call and writes NOTHING.
653
- const maybeWriteSessionAsset = async () => {
654
- if (!sessionIndexing.enabled || dryRun)
655
- return {};
656
- if (!sessionMeetsDurationGate(data, sessionIndexing.minDurationMinutes))
657
- return {};
658
- try {
659
- const result = await writeSessionAsset(data, stashDir, (summaryData) => sessionIndexing.generate(summaryData, lease));
660
- if (result.written) {
661
- // Write-path indexing (itself fail-open): standalone `akm extract`
662
- // (session-end hook) has no post-loop reindex to pick this file up.
663
- if (result.filePath)
664
- await indexWrittenAssets(stashDir, [result.filePath]);
665
- return {
666
- ...(result.ref ? { sessionAssetRef: result.ref } : {}),
667
- ...(result.logPath ? { sessionLogPath: result.logPath } : {}),
668
- };
669
- }
670
- }
671
- catch (err) {
672
- if (err instanceof ConfigError)
673
- throw err;
674
- warnings.push(`session asset write failed: ${err instanceof Error ? err.message : String(err)}`);
675
- }
676
- return {};
677
- };
678
680
  const extraction = await runSessionExtractionLlmCall({
679
681
  config,
680
682
  llmRunner,
@@ -708,7 +710,9 @@ async function processSession(runCtx, session) {
708
710
  const { payload } = extraction;
709
711
  const proposalIds = [];
710
712
  // Provenance refs are added only after the cited session asset exists.
711
- const sessionAsset = await maybeWriteSessionAsset();
713
+ const sessionAsset = await maybeWriteSessionAsset(runCtx, session);
714
+ if (sessionAsset.warning)
715
+ warnings.push(sessionAsset.warning);
712
716
  if (payload.candidates.length === 0) {
713
717
  appendEvent({
714
718
  eventType: "extract_invoked",
@@ -52,10 +52,17 @@ function readEdgeList(value) {
52
52
  * Write `contradictedBy` and `beliefState: contradicted` edges to a memory
53
53
  * file's frontmatter (C-3 / #382).
54
54
  *
55
- * This is the shared primitive used by:
56
- * - `akmConsolidate` when its LLM plan includes a `contradict` op
57
- * - `memory-contradiction-detect.ts` for the M-1 automated contradiction pass
58
- * - `resolveFamilyContradictions` in `memory-improve.ts` for SCC resolution
55
+ * The shared primitive for APPENDING one contradiction edge. Used by
56
+ * `memory-contradiction-detect.ts`'s automated contradiction pass.
57
+ *
58
+ * NOT used by `persistBeliefStateTransition` in `memory-improve.ts`, and that
59
+ * is deliberate (#885): the SCC resolver is a state-TRANSITION writer, not an
60
+ * edge appender. It replaces `contradictedBy` wholesale from recomputed
61
+ * `currentBeliefRefs`, deletes the key when a memory transitions away from
62
+ * `contradicted`, and moves a memory to any target state including back to
63
+ * active. An append-only primitive that never weakens a demotion cannot
64
+ * express those, and routing it through this would break the resolver's
65
+ * ability to clear an edge.
59
66
  *
60
67
  * Idempotent: if the `contradictedByRef` is already in `contradictedBy` AND
61
68
  * the file already carries the demotion state, the file is not rewritten. The
@@ -72,9 +79,12 @@ function readEdgeList(value) {
72
79
  *
73
80
  * @param filePath - Absolute path to the memory markdown file.
74
81
  * @param contradictedByRef - The ref that contradicts this memory.
82
+ * @returns `true` when the file was rewritten, `false` when the edge and the
83
+ * demotion were already present (the idempotent no-op). Callers count
84
+ * edges written from this.
75
85
  */
76
86
  export function writeContradictEdge(filePath, contradictedByRef) {
77
- mutateFrontmatter(filePath, (parsed) => {
87
+ return mutateFrontmatter(filePath, (parsed) => {
78
88
  const existing = readEdgeList(parsed.data.contradictedBy);
79
89
  const currentState = parsed.data.beliefState;
80
90
  const nextState = currentState === "archived" ? currentState : "contradicted";
@@ -17,8 +17,19 @@
17
17
  * 2. For each family, enumerate candidate pairs.
18
18
  * 3. For each pair, call the LLM to judge whether the two memories are in
19
19
  * direct factual conflict.
20
- * 4. For confirmed contradictions, write `contradictedBy` edges directly to
21
- * the losing memory's frontmatter (same mechanism as `persistBeliefStateTransition`).
20
+ * 4. For confirmed contradictions, append a `contradictedBy` edge to the
21
+ * losing memory's frontmatter via `writeContradictEdge`
22
+ * (`./memory-belief.ts`).
23
+ *
24
+ * That last step used to call a private near-copy of `writeContradictEdge`
25
+ * living in this file. The copy had drifted (#885): it read `contradictedBy`
26
+ * with `Array.isArray` only, so a SCALAR edge — live data the indexer accepts
27
+ * and lint never flags — read as "no edges" and was overwritten out of
28
+ * existence; and it set `beliefState: "contradicted"` unconditionally,
29
+ * promoting an `archived` memory back up (archived ranks BELOW contradicted).
30
+ * Both behaviors had tests, but the tests exercised the shared primitive,
31
+ * which nothing called — so they guarded dead code while the live path
32
+ * carried the bugs.
22
33
  *
23
34
  * # LLM Feature Gate
24
35
  *
@@ -36,12 +47,13 @@
36
47
  import fs from "node:fs";
37
48
  import path from "node:path";
38
49
  import contradictionJudgeTemplate from "../../../assets/prompts/contradiction-judge.md" with { type: "text" };
39
- import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js";
50
+ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
40
51
  import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
41
52
  import { disposeLoweredExecutionDispatchLease } from "../../../integrations/agent/execution-lowering.js";
42
53
  import { callStructured, preflightStructuredLlmRunner } from "../../../llm/structured-call.js";
43
54
  import { resolveImproveLlmExecution } from "../execution.js";
44
55
  import { isDerivedMemory, memoryIdentityRef, resolveParentRef } from "./derived-ref.js";
56
+ import { writeContradictEdge } from "./memory-belief.js";
45
57
  // ── Constants ────────────────────────────────────────────────────────────────
46
58
  /**
47
59
  * Minimum confidence required to write a contradiction edge. Below this
@@ -89,27 +101,6 @@ function toMemoryRef(memoriesDir, filePath) {
89
101
  return memoryIdentityRef(name);
90
102
  }
91
103
  // ── Edge writing ─────────────────────────────────────────────────────────────
92
- /**
93
- * Write a `contradictedBy` edge to the losing memory's frontmatter file.
94
- * Preserves all existing frontmatter keys; only adds/updates `contradictedBy`
95
- * and `beliefState: contradicted`.
96
- */
97
- /** Returns true if the edge was newly written, false if it already existed. */
98
- function writeContradictedByEdge(filePath, contradictedByRef) {
99
- return mutateFrontmatter(filePath, (parsed) => {
100
- const existing = Array.isArray(parsed.data.contradictedBy)
101
- ? parsed.data.contradictedBy
102
- : [];
103
- if (existing.includes(contradictedByRef))
104
- return null; // Edge already written.
105
- const updatedContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
106
- return {
107
- ...parsed.data,
108
- contradictedBy: updatedContradictedBy,
109
- beliefState: "contradicted",
110
- };
111
- });
112
- }
113
104
  /**
114
105
  * Deterministically pick, for a confirmed-contradiction pair, the LOSER memory
115
106
  * that receives the single directed `contradictedBy` edge (SCC-resolved to
@@ -284,7 +275,7 @@ export async function detectAndWriteContradictions(stashDir, config, chat, strat
284
275
  // a 2-cycle that the SCC resolver refreshes back to active, erasing the
285
276
  // contradiction every run (see pickContradictionLoser).
286
277
  try {
287
- const wrote = writeContradictedByEdge(loser.filePath, winnerRef);
278
+ const wrote = writeContradictEdge(loser.filePath, winnerRef);
288
279
  result.edgesWritten += wrote ? 1 : 0;
289
280
  }
290
281
  catch (err) {
@@ -9,7 +9,9 @@ import { MEMORY_ARCHIVE_REL } from "../../../core/asset/memory-archive.js";
9
9
  import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
10
10
  import { asNonEmptyString, groupBy, stringArray } from "../../../core/common.js";
11
11
  import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
12
+ import { warn } from "../../../core/warn.js";
12
13
  import { recordWrittenPath } from "../../../core/write-provenance.js";
14
+ import { walkMarkdownFiles } from "../../../indexer/walk/walker.js";
13
15
  import { isDerivedMemory, memoryIdentityRef, parseMemoryName, resolveParentRef } from "./derived-ref.js";
14
16
  export function analyzeMemoryCleanup(stashDir, options = {}) {
15
17
  const records = collectDerivedMemories(stashDir, options.parentRef);
@@ -574,7 +576,11 @@ function collectDerivedMemories(stashDir, parentRefFilter) {
574
576
  if (!fs.existsSync(memoriesDir))
575
577
  return [];
576
578
  const records = [];
577
- for (const filePath of walkMarkdownFiles(memoriesDir)) {
579
+ const walked = walkMarkdownFiles(memoriesDir);
580
+ if (!walked.complete) {
581
+ warn(`memory improve: directory scan under ${memoriesDir} is incomplete — some derived memories may be missing`);
582
+ }
583
+ for (const filePath of walked.files) {
578
584
  const name = toMemoryName(memoriesDir, filePath);
579
585
  if (!name)
580
586
  continue;
@@ -778,24 +784,6 @@ function firstNonEmpty(values) {
778
784
  }
779
785
  return undefined;
780
786
  }
781
- function* walkMarkdownFiles(root) {
782
- let entries;
783
- try {
784
- entries = fs.readdirSync(root, { withFileTypes: true });
785
- }
786
- catch {
787
- return;
788
- }
789
- for (const entry of entries) {
790
- const full = path.join(root, entry.name);
791
- if (entry.isDirectory()) {
792
- yield* walkMarkdownFiles(full);
793
- }
794
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
795
- yield full;
796
- }
797
- }
798
- }
799
787
  function toMemoryName(memoriesDir, filePath) {
800
788
  const rel = path.relative(memoriesDir, filePath);
801
789
  if (!rel || rel.startsWith(".."))
@@ -789,6 +789,14 @@ export async function runImprovePreparationStage(args) {
789
789
  snapshot,
790
790
  persist: !planOnly,
791
791
  });
792
+ const eligibilitySourceByRef = stampEligibilitySource({
793
+ scope,
794
+ processableRefs: gathered.processableRefs,
795
+ mergedRefs: gathered.mergedRefs,
796
+ signalFiltered: gathered.signalFiltered,
797
+ proactiveRefs: gathered.proactiveRefs,
798
+ highSalienceRefs: gathered.highSalienceRefs,
799
+ });
792
800
  // Shared admission boundary for every synthetic fallback lane. Cleanup and
793
801
  // structural validation are exclusive selectors: no later rank/replay state
794
802
  // may re-create a candidate they removed. Keep the exact surviving objects
@@ -800,7 +808,7 @@ export async function runImprovePreparationStage(args) {
800
808
  primaryStashDir,
801
809
  eventsCtx,
802
810
  mergedRefs: gathered.mergedRefs,
803
- eligibilitySourceByRef: gathered.eligibilitySourceByRef,
811
+ eligibilitySourceByRef,
804
812
  feedbackSummary: gathered.feedbackSummary,
805
813
  retrievalCounts: gathered.retrievalCounts,
806
814
  signalFiltered: gathered.signalFiltered,
@@ -820,7 +828,7 @@ export async function runImprovePreparationStage(args) {
820
828
  eventsCtx,
821
829
  mergedRefs: scored.mergedRefs,
822
830
  salienceMap: scored.salienceMap,
823
- eligibilitySourceByRef: gathered.eligibilitySourceByRef,
831
+ eligibilitySourceByRef,
824
832
  distillOnlyRefs: gathered.distillOnlyRefs,
825
833
  validationFailureRefs,
826
834
  summary: {
@@ -1054,7 +1062,9 @@ export function buildSnapshotManifest(args) {
1054
1062
  /**
1055
1063
  * Pass: candidate-gather — the signal-delta partition, the bulk feedback
1056
1064
  * summary, retrieval signals, the Layer-2 proactive and Layer-3 high-salience
1057
- * rescue lanes, the merged candidate set, and lane attribution stamping.
1065
+ * rescue lanes, and the merged candidate set. Lane attribution stamping is a
1066
+ * separate pass — see `stampEligibilitySource` — run by the caller once
1067
+ * `mergedRefs` is known.
1058
1068
  */
1059
1069
  function gatherCandidates(args) {
1060
1070
  const { scope, options, primaryStashDir, eventsCtx, improveProfile, resolvedPlan, postCleanupRefs, persist } = args;
@@ -1152,23 +1162,44 @@ function gatherCandidates(args) {
1152
1162
  // that want feedback-only runs.
1153
1163
  const signalAndRetrievalRefs = dedupeRefs([...signalFiltered, ...proactiveRefs, ...highSalienceRefs]);
1154
1164
  const mergedRefs = scope.mode === "ref" ? processableRefs : options.requireFeedbackSignal ? signalFiltered : signalAndRetrievalRefs;
1155
- // ── Attribution tagging: stamp each ref with the eligibility lane that
1156
- // selected it ──────────────────────────────────────────────────────────────
1157
- // Every reflect/distill proposal must record WHICH lane chose its source asset
1158
- // so downstream accept/reject/revert/retrieval outcomes can be sliced by lane
1159
- // (does the PROACTIVE lane produce value vs the reactive lanes?). We build the
1160
- // lane map here — the one place all three lanes are known — and stamp it onto
1161
- // each ImproveEligibleRef object. Because the ref objects are shared by
1162
- // reference across buckets, the stamp travels with the ref through the sort,
1163
- // disk-check, and loop stages down to the reflect/distill event emit sites and
1164
- // createProposal calls. See EligibilitySource for the lane vocabulary.
1165
- //
1166
- // Precedence (prefer the most specific reactive signal):
1167
- // scope > signal-delta > proactive > high-salience
1168
- // A ref with real feedback is attributed to feedback even if it was also due
1169
- // for proactive maintenance or had high encoding salience. We apply lanes
1170
- // weakest-first so the strongest overwrites; the explicit --scope <ref> bypass
1171
- // wins outright (user intent).
1165
+ return {
1166
+ distillCooledRefs,
1167
+ preCooldownCount,
1168
+ distillOnlyRefs,
1169
+ feedbackSummary,
1170
+ signalFiltered,
1171
+ signalBearingSet,
1172
+ retrievalCounts,
1173
+ proactiveRefs,
1174
+ proactiveMaintenanceSummary,
1175
+ proactivePlan: proactive.proactivePlan,
1176
+ highSalienceRefs,
1177
+ signalAndRetrievalRefs,
1178
+ mergedRefs,
1179
+ processableRefs,
1180
+ };
1181
+ }
1182
+ /**
1183
+ * Attribution tagging: stamp each ref with the eligibility lane that selected
1184
+ * it. Every reflect/distill proposal must record WHICH lane chose its source
1185
+ * asset so downstream accept/reject/revert/retrieval outcomes can be sliced by
1186
+ * lane (does the PROACTIVE lane produce value vs the reactive lanes?). We
1187
+ * build the lane map here — the one place all three lanes are known — and
1188
+ * stamp it onto each ImproveEligibleRef object. Because the ref objects are
1189
+ * shared by reference across buckets, the stamp travels with the ref through
1190
+ * the sort, disk-check, and loop stages down to the reflect/distill event
1191
+ * emit sites and createProposal calls. See EligibilitySource for the lane
1192
+ * vocabulary.
1193
+ *
1194
+ * Precedence (prefer the most specific reactive signal):
1195
+ * scope > signal-delta > proactive > high-salience
1196
+ * A ref with real feedback is attributed to feedback even if it was also due
1197
+ * for proactive maintenance or had high encoding salience. We apply lanes
1198
+ * weakest-first so the strongest overwrites; the explicit --scope <ref> bypass
1199
+ * wins outright (user intent).
1200
+ */
1201
+ function stampEligibilitySource(args) {
1202
+ const { scope, processableRefs, mergedRefs, signalFiltered, proactiveRefs, highSalienceRefs } = args;
1172
1203
  const eligibilitySourceByRef = new Map();
1173
1204
  for (const r of highSalienceRefs)
1174
1205
  eligibilitySourceByRef.set(r.ref, "high-salience");
@@ -1188,22 +1219,7 @@ function gatherCandidates(args) {
1188
1219
  // mergedRefs is always a subset of the four lanes above).
1189
1220
  r.eligibilitySource = eligibilitySourceByRef.get(r.ref) ?? "unknown";
1190
1221
  }
1191
- return {
1192
- distillCooledRefs,
1193
- preCooldownCount,
1194
- distillOnlyRefs,
1195
- feedbackSummary,
1196
- signalFiltered,
1197
- signalBearingSet,
1198
- retrievalCounts,
1199
- proactiveRefs,
1200
- proactiveMaintenanceSummary,
1201
- proactivePlan: proactive.proactivePlan,
1202
- highSalienceRefs,
1203
- signalAndRetrievalRefs,
1204
- mergedRefs,
1205
- eligibilitySourceByRef,
1206
- };
1222
+ return eligibilitySourceByRef;
1207
1223
  }
1208
1224
  /**
1209
1225
  * The signal-delta partition of postCleanupRefs into the four buckets (pass:
@@ -133,27 +133,23 @@ export const REFLECT_ALLOWED_TYPES = new Set([
133
133
  const PROTECTED_FRONTMATTER_FIELDS = new Set(["name", "ref", "id", "slug", "type"]);
134
134
  /**
135
135
  * Read the last 1–3 archived rejected proposals for a given ref from the
136
- * proposal store. Best-effort — returns `[]` when the proposals dir is absent
137
- * or the ref is undefined. Used to inject Reflexion-style verbal-RL context
138
- * into the reflect prompt so the agent avoids re-proposing already-refused
139
- * content (arXiv:2303.11366).
136
+ * proposal store. Returns `[]` when the proposals store is absent (not yet
137
+ * created) or the ref is undefined `listProposalsReadOnly` already handles
138
+ * that case; a genuine read failure propagates instead of being swallowed,
139
+ * since silently dropping this Reflexion-style context risks re-proposing
140
+ * content that was already rejected (arXiv:2303.11366).
140
141
  */
141
142
  function readRejectedProposals(stash, ref, proposalsCtx) {
142
143
  if (!ref)
143
144
  return [];
144
- try {
145
- return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
146
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
147
- .slice(0, MAX_REJECTED_PROPOSALS)
148
- .map((p) => ({
149
- ref: p.ref,
150
- reason: p.review?.reason ?? "no reason given",
151
- contentPreview: proposalContent(p).slice(0, 500),
152
- }));
153
- }
154
- catch {
155
- return [];
156
- }
145
+ return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
146
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
147
+ .slice(0, MAX_REJECTED_PROPOSALS)
148
+ .map((p) => ({
149
+ ref: p.ref,
150
+ reason: p.review?.reason ?? "no reason given",
151
+ contentPreview: proposalContent(p).slice(0, 500),
152
+ }));
157
153
  }
158
154
  /**
159
155
  * Synthesize a tmp draft-file path for the agent/sdk file-write contract.
@@ -36,10 +36,10 @@ import path from "node:path";
36
36
  import { isScalar, parseDocument } from "yaml";
37
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 { removeFrontmatterListValues, spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
39
+ import { removeFrontmatterListValues, rewriteFrontmatterListValue, spliceFrontmatterLine, } from "../../core/asset/frontmatter.js";
40
40
  import { checkUnquotedDescriptionColon } from "../../core/asset/frontmatter-lint.js";
41
41
  import { isArchivedRelPath } from "../../core/asset/memory-archive.js";
42
- import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
42
+ import { conceptIdFromTypeName, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
43
43
  import { localDateStamp } from "../../core/common.js";
44
44
  import { findFenceRegions } from "./markdown-insertion.js";
45
45
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -786,6 +786,46 @@ function runMissingRefChecks(ctx, raw, shouldRun, pendingFixes) {
786
786
  const values = readRefStringOrArray(ctx.data?.[key]);
787
787
  if (values === null)
788
788
  continue;
789
+ // `lint --fix` migration for the retired `type:slug` xref grammar
790
+ // (see legacyTypeSlugParts): rewrite a value to its conceptId form
791
+ // ONLY when the rewritten spelling still resolves to a real asset —
792
+ // a dangling legacy ref must stay reported as `missing-ref` in its
793
+ // original spelling, never rewritten into a differently-spelled
794
+ // dangling ref. Runs before the missing-ref scan below so a value
795
+ // this loop rewrites is checked (and reported, if still dangling)
796
+ // under its ORIGINAL spelling by that scan, and never double-fixed.
797
+ if (ctx.fix) {
798
+ const legacyRewrites = new Map();
799
+ for (const raw of values) {
800
+ const value = raw.trim();
801
+ const parts = legacyTypeSlugParts(value);
802
+ if (parts === undefined)
803
+ continue;
804
+ const relPath = refToRelPath(parts.type, parts.name);
805
+ if (relPath === null)
806
+ continue;
807
+ if (!refExistsInAnyStash(relPath, parts.type, parts.name, [ctx.stashRoot, ...(ctx.extraStashRoots ?? [])]))
808
+ continue; // dangling — leave reported as missing-ref, unrewritten
809
+ legacyRewrites.set(value, conceptIdFromTypeName(parts.type, parts.name));
810
+ }
811
+ if (legacyRewrites.size > 0) {
812
+ const rewritten = rewriteFrontmatterListValue(currentRaw, key, legacyRewrites);
813
+ if (rewritten !== null) {
814
+ currentRaw = rewritten;
815
+ modified = true;
816
+ for (const [oldValue, newValue] of legacyRewrites) {
817
+ const issue = {
818
+ file: ctx.relPath,
819
+ issue: "missing-ref",
820
+ detail: `legacy xref grammar migrated: ${key} ${oldValue} -> ${newValue}`,
821
+ fixed: true,
822
+ };
823
+ issues.push(issue);
824
+ pendingFixes.push(issue);
825
+ }
826
+ }
827
+ }
828
+ }
789
829
  const missingXrefs = checkMissingRefsInList(values, ctx.stashRoot, ctx.extraStashRoots);
790
830
  // #884 opt-in repair. Scoped to the BELIEF channels only: an edge whose
791
831
  // target has neither a file nor a prune tombstone asserts a