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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/assets/templates/html/default.html +78 -0
  3. package/dist/assets/templates/html/health.html +560 -0
  4. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  5. package/dist/cli/shared.js +21 -5
  6. package/dist/cli.js +36 -5
  7. package/dist/commands/health/html-report.js +448 -0
  8. package/dist/commands/health.js +97 -6
  9. package/dist/commands/improve/extract.js +38 -2
  10. package/dist/commands/improve/improve-auto-accept.js +27 -1
  11. package/dist/commands/improve/improve.js +167 -53
  12. package/dist/commands/improve/reflect-noise.js +0 -0
  13. package/dist/commands/improve/reflect.js +25 -0
  14. package/dist/commands/proposal/drain.js +73 -6
  15. package/dist/commands/proposal/proposal-cli.js +22 -10
  16. package/dist/commands/proposal/proposal.js +12 -1
  17. package/dist/commands/proposal/validators/proposals.js +361 -338
  18. package/dist/commands/remember.js +6 -2
  19. package/dist/core/config/config-schema.js +5 -0
  20. package/dist/core/logs-db.js +304 -0
  21. package/dist/core/state-db.js +107 -14
  22. package/dist/indexer/db/db.js +2 -2
  23. package/dist/indexer/passes/memory-inference.js +61 -22
  24. package/dist/integrations/harnesses/claude/session-log.js +16 -4
  25. package/dist/llm/client.js +15 -0
  26. package/dist/llm/usage-persist.js +77 -0
  27. package/dist/llm/usage-telemetry.js +103 -0
  28. package/dist/output/context.js +3 -2
  29. package/dist/output/html-render.js +73 -0
  30. package/dist/output/shapes/helpers.js +17 -1
  31. package/dist/output/text/helpers.js +69 -1
  32. package/dist/scripts/migrate-storage.js +65 -14
  33. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +14 -2
  34. package/dist/tasks/runner.js +99 -16
  35. package/dist/workflows/db.js +4 -0
  36. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } f
12
12
  import { appendEvent, readEvents } from "../../core/events.js";
13
13
  import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../../core/file-lock.js";
14
14
  import { classifyImproveAction } from "../../core/improve-types.js";
15
+ import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
15
16
  import { getDbPath, getStateDbPathInDataDir } from "../../core/paths.js";
16
17
  import { openStateDatabase, purgeOldEvents, purgeOldImproveRuns } from "../../core/state-db.js";
17
18
  import { info, warn } from "../../core/warn.js";
@@ -27,6 +28,8 @@ import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
27
28
  import { resolveImproveProcessRunnerFromProfile, resolveTriageJudgmentRunner } from "../../integrations/agent/runner.js";
28
29
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
29
30
  import { isLlmFeatureEnabled, isProcessEnabled } from "../../llm/feature-gate.js";
31
+ import { installLlmUsagePersistence } from "../../llm/usage-persist.js";
32
+ import { withLlmStage } from "../../llm/usage-telemetry.js";
30
33
  import { isGitBackedStash, resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
31
34
  import { akmLint } from "../lint/index.js";
32
35
  import { drainProposals } from "../proposal/drain.js";
@@ -112,7 +115,7 @@ async function collectEligibleRefs(scope, stashDir, improveProfile) {
112
115
  };
113
116
  }
114
117
  return {
115
- plannedRefs: [{ ref: scope.value, reason: "scope-ref" }],
118
+ plannedRefs: [{ ref: scope.value, reason: "scope-ref", filePath }],
116
119
  memorySummary: {
117
120
  eligible: parsed.type === "memory" ? 1 : 0,
118
121
  derived: parsed.type === "memory" && parsed.name.endsWith(".derived") ? 1 : 0,
@@ -176,12 +179,14 @@ async function collectEligibleRefs(scope, stashDir, improveProfile) {
176
179
  profileFiltered.set(ref, {
177
180
  ref,
178
181
  reason: "profile_filtered_all_passes",
182
+ filePath: indexed.filePath,
179
183
  });
180
184
  }
181
185
  else {
182
186
  planned.set(ref, {
183
187
  ref,
184
188
  reason: scope.mode === "type" ? "scope-type" : indexed.entry.type === "memory" ? "memory-cleanup" : "scope-type",
189
+ filePath: indexed.filePath,
185
190
  });
186
191
  }
187
192
  }
@@ -717,7 +722,7 @@ export async function akmImprove(options = {}) {
717
722
  if (primaryStashDir && shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)) {
718
723
  try {
719
724
  // Reuse the config resolved at the top of the run instead of a second load.
720
- await detectAndWriteContradictions(primaryStashDir, _earlyConfig);
725
+ await withLlmStage("memory-contradiction", () => detectAndWriteContradictions(primaryStashDir, _earlyConfig));
721
726
  }
722
727
  catch (err) {
723
728
  // Non-fatal: contradiction detection is a best-effort pass.
@@ -777,6 +782,9 @@ export async function akmImprove(options = {}) {
777
782
  // Pinned to the boundary snapshot so the fallback per-call `appendEvent`
778
783
  // opens (when the long-lived handle below fails to open) never re-read env.
779
784
  let eventsCtx = { dbPath: resolvedStateDbPath };
785
+ // #576: clears the per-run LLM usage sink. Defaults to a no-op until the sink
786
+ // is installed inside the try; the `finally` always calls it.
787
+ let disposeLlmUsageSink = () => { };
780
788
  try {
781
789
  // H7 (#566): arm the budget watchdog. `armBudgetWatchdog` captures both the
782
790
  // budget timer and the hard-kill timer it schedules on exhaustion, returning
@@ -796,17 +804,26 @@ export async function akmImprove(options = {}) {
796
804
  // still pinned to the boundary-resolved path, never a live env re-read.
797
805
  eventsCtx = { dbPath: resolvedStateDbPath };
798
806
  }
799
- // 2026-05-27: emit `improve_skipped` audit events for refs the planner
807
+ // #576: persist per-call LLM usage telemetry for this run as `llm_usage`
808
+ // events, reusing the same boundary-pinned events context (and long-lived
809
+ // handle when available). Disposed in `finally` so the sink never leaks
810
+ // across runs. Wrapping is best-effort end to end — see usage-telemetry.ts.
811
+ disposeLlmUsageSink = installLlmUsagePersistence(eventsCtx);
812
+ // 2026-05-27: emit an `improve_skipped` audit event for refs the planner
800
813
  // pre-filtered (reflect AND distill both refuse them under the active
801
- // profile). One event per ref so the existing improve_skipped histogram in
802
- // `health.ts#improveSummary.skipReasons` accumulates the right count under
803
- // the new `profile_filtered_all_passes` reason code. See
804
- // `/tmp/akm-health-investigations/planner-profile-metrics-deep-analysis.md`.
805
- for (const filtered of profileFilteredRefs) {
814
+ // profile). Emitted as a single summary event (count only) rather than one
815
+ // event per ref (#592) the per-ref loop caused O(n) sequential state.db
816
+ // writes that consumed ~500 s on a 9 000-ref stash. No downstream consumer
817
+ // needs the per-ref audit trail: health's skip histogram reads the
818
+ // `profile_filtered_all_passes` counters from `improve_completed` metadata.
819
+ if (profileFilteredRefs.length > 0) {
806
820
  appendEvent({
807
821
  eventType: "improve_skipped",
808
- ref: filtered.ref,
809
- metadata: { reason: "profile_filtered_all_passes" },
822
+ ref: undefined,
823
+ metadata: {
824
+ reason: "profile_filtered_all_passes",
825
+ count: profileFilteredRefs.length,
826
+ },
810
827
  }, eventsCtx);
811
828
  }
812
829
  const preparation = await runImprovePreparationStage({
@@ -1031,6 +1048,9 @@ export async function akmImprove(options = {}) {
1031
1048
  throw err;
1032
1049
  }
1033
1050
  finally {
1051
+ // #576: clear the per-run LLM usage sink BEFORE closing `eventsDb` below, so
1052
+ // no late sink invocation can write through a closed handle.
1053
+ disposeLlmUsageSink();
1034
1054
  // O-1 (#364): Clear the budget abort timer so it does not keep the event
1035
1055
  // loop alive after the run completes.
1036
1056
  clearBudgetTimer();
@@ -1357,7 +1377,7 @@ async function runConsolidationPass(args) {
1357
1377
  info(`[improve] consolidation skipped (pool ${eligiblePoolSize} < minPoolSize ${minPoolSize})`);
1358
1378
  }
1359
1379
  else if (!consolidationOnCooldown) {
1360
- consolidation = await akmConsolidate({
1380
+ consolidation = await withLlmStage("consolidate", () => akmConsolidate({
1361
1381
  ...options.consolidateOptions,
1362
1382
  config: consolidationConfig,
1363
1383
  stashDir: options.stashDir,
@@ -1380,7 +1400,7 @@ async function runConsolidationPass(args) {
1380
1400
  // options.consolidateOptions.autoAccept (if explicitly provided by caller)
1381
1401
  // still wins because the spread above runs first.
1382
1402
  autoAccept: options.consolidateOptions?.autoAccept ?? options.autoAccept,
1383
- });
1403
+ }));
1384
1404
  {
1385
1405
  const consolidateGr = await runAutoAcceptGate(consolidation.promoted.map((proposalId) => {
1386
1406
  try {
@@ -1467,7 +1487,9 @@ async function runImprovePreparationStage(args) {
1467
1487
  // / `akm feedback` invocations. Replaces the akm-plugin session-checkpoint
1468
1488
  // hook with an on-demand pull pipeline.
1469
1489
  //
1470
- // Default-on; opt out via `profiles.improve.default.processes.extract.enabled: false`.
1490
+ // Default-on; opt out via the ACTIVE profile's `processes.extract.enabled: false`
1491
+ // (#593: the gate respects the resolved improve profile, not just the
1492
+ // hardcoded `default` profile path the legacy feature flag reads).
1471
1493
  // Each available harness gets one call with the default --since window;
1472
1494
  // already-seen sessions (tracked in state.db.extract_sessions_seen) are
1473
1495
  // skipped automatically so re-runs don't burn LLM calls on unchanged data.
@@ -1501,7 +1523,13 @@ async function runImprovePreparationStage(args) {
1501
1523
  const EXTRACT_DEFAULT_MIN_NEW_SESSIONS = 0;
1502
1524
  const configuredMinNewSessions = extractConfig.profiles?.improve?.default?.processes?.extract?.minNewSessions;
1503
1525
  const minNewSessions = typeof configuredMinNewSessions === "number" ? configuredMinNewSessions : EXTRACT_DEFAULT_MIN_NEW_SESSIONS;
1504
- if (isLlmFeatureEnabled(extractConfig, "session_extraction")) {
1526
+ // #593: gate on BOTH the legacy feature flag (which only reads
1527
+ // `profiles.improve.default.processes.extract.enabled` — kept for back-compat
1528
+ // with users who disable extract via the default-profile path) AND the active
1529
+ // resolved profile. Without the second check a non-default profile setting
1530
+ // `extract.enabled: false` (e.g. the built-in `quick`) was silently ignored
1531
+ // and extract ran on every improve call regardless.
1532
+ if (isLlmFeatureEnabled(extractConfig, "session_extraction") && resolveProcessEnabled("extract", improveProfile)) {
1505
1533
  const availableHarnesses = options.extractHarnesses ?? getAvailableHarnesses();
1506
1534
  // The guard engages only when minNewSessions > 0; 0 disables it entirely.
1507
1535
  let belowMinNewSessions = false;
@@ -1532,7 +1560,7 @@ async function runImprovePreparationStage(args) {
1532
1560
  extractResults = [];
1533
1561
  for (const h of availableHarnesses) {
1534
1562
  try {
1535
- const result = await akmExtract({
1563
+ const result = await withLlmStage("session-extraction", () => akmExtract({
1536
1564
  type: h.name,
1537
1565
  ...(primaryStashDir !== undefined ? { stashDir: primaryStashDir } : {}),
1538
1566
  config: extractConfig,
@@ -1540,7 +1568,7 @@ async function runImprovePreparationStage(args) {
1540
1568
  ...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
1541
1569
  // C2: pin extract's skip-tracking state.db open to the boundary path.
1542
1570
  ...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
1543
- });
1571
+ }));
1544
1572
  extractResults.push(result);
1545
1573
  {
1546
1574
  const gr = await runAutoAcceptGate(primaryStashDir
@@ -1631,7 +1659,13 @@ async function runImprovePreparationStage(args) {
1631
1659
  const validationFailures = [];
1632
1660
  for (const candidate of postCleanupRefs) {
1633
1661
  try {
1634
- const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
1662
+ // #591: use the path pre-resolved at planning time when it is still on
1663
+ // disk — a serial async DB lookup per ref cost ~500 s on a 9 000-ref
1664
+ // stash. Fall back to findAssetFilePath only for refs that bypassed
1665
+ // collectEligibleRefs' index scan or whose file moved since planning.
1666
+ const filePath = candidate.filePath && fs.existsSync(candidate.filePath)
1667
+ ? candidate.filePath
1668
+ : await findAssetFilePath(candidate.ref, options.stashDir);
1635
1669
  if (!filePath) {
1636
1670
  validationFailures.push({ ref: candidate.ref, reason: "file not found on disk" });
1637
1671
  continue;
@@ -1939,15 +1973,32 @@ async function runImprovePreparationStage(args) {
1939
1973
  const assetMissingOnDisk = [];
1940
1974
  const existsCheckedActionable = [];
1941
1975
  for (const candidate of sorted) {
1942
- const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
1976
+ // #591: prefer the path pre-resolved at planning time (synchronous
1977
+ // existsSync) over a serial async DB lookup per ref.
1978
+ const filePath = candidate.filePath && fs.existsSync(candidate.filePath)
1979
+ ? candidate.filePath
1980
+ : await findAssetFilePath(candidate.ref, options.stashDir);
1943
1981
  if (filePath && fs.existsSync(filePath)) {
1944
1982
  existsCheckedActionable.push(candidate);
1945
1983
  }
1946
1984
  else {
1947
1985
  assetMissingOnDisk.push(candidate.ref);
1948
- appendEvent({ eventType: "improve_skipped", ref: candidate.ref, metadata: { reason: "asset_missing_on_disk" } }, eventsCtx);
1949
1986
  }
1950
1987
  }
1988
+ // #592 audit: one summary event instead of one per missing ref. Normally
1989
+ // tiny, but a stash deletion racing the run could make this O(n) sequential
1990
+ // state.db writes. `refs` is capped so the metadata row stays bounded.
1991
+ if (assetMissingOnDisk.length > 0) {
1992
+ appendEvent({
1993
+ eventType: "improve_skipped",
1994
+ ref: undefined,
1995
+ metadata: {
1996
+ reason: "asset_missing_on_disk",
1997
+ count: assetMissingOnDisk.length,
1998
+ refs: assetMissingOnDisk.slice(0, 50),
1999
+ },
2000
+ }, eventsCtx);
2001
+ }
1951
2002
  const actionableRefs = existsCheckedActionable;
1952
2003
  // Re-split actionableRefs (sorted) into reflect-path vs distill-only-path while
1953
2004
  // preserving sort order. distillOnlyRefs participate in the sort so --limit
@@ -2188,9 +2239,11 @@ async function runImproveLoopStage(args) {
2188
2239
  if (remainingBudgetMs() <= 0)
2189
2240
  break;
2190
2241
  // draftMode: skip DB write so each sample doesn't create a proposal.
2191
- samples.push(await reflectFn({ ...reflectCallArgs, draftMode: true }));
2242
+ samples.push(await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true })));
2192
2243
  }
2193
- const winner = pickMajorityVote(samples.length > 0 ? samples : [await reflectFn({ ...reflectCallArgs, draftMode: true })]);
2244
+ const winner = pickMajorityVote(samples.length > 0
2245
+ ? samples
2246
+ : [await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true }))]);
2194
2247
  // Persist only the majority-vote winner as a single real proposal.
2195
2248
  if (winner.ok && primaryStashDir) {
2196
2249
  const persistResult = createProposal(primaryStashDir, {
@@ -2215,7 +2268,7 @@ async function runImproveLoopStage(args) {
2215
2268
  }
2216
2269
  }
2217
2270
  else {
2218
- reflectResult = await reflectFn(reflectCallArgs);
2271
+ reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs));
2219
2272
  }
2220
2273
  const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
2221
2274
  // Content-policy guard hits (reflect size-rail rejections) are NOT
@@ -2232,6 +2285,12 @@ async function runImproveLoopStage(args) {
2232
2285
  // user's stack were this case; see review §1a row "Reflect refused
2233
2286
  // asset type".
2234
2287
  const isTypeRefused = !reflectResult.ok && reflectResult.reason === "unsupported_type";
2288
+ // Noise-gate suppression (#580): the candidate edit was an empty
2289
+ // diff or a cosmetic-only reformat of the current asset. Like
2290
+ // `unsupported_type`, this is a deterministic skip — not an LLM
2291
+ // fault — so it routes to the `reflect-skipped` bucket and stays
2292
+ // out of recentErrors/avoidPatterns.
2293
+ const isNoChange = !reflectResult.ok && reflectResult.reason === "no_change";
2235
2294
  actions.push({
2236
2295
  ref: planned.ref,
2237
2296
  mode: reflectResult.ok
@@ -2240,18 +2299,19 @@ async function runImproveLoopStage(args) {
2240
2299
  ? "reflect-cooldown"
2241
2300
  : isGuardReject
2242
2301
  ? "reflect-guard-rejected"
2243
- : isTypeRefused
2302
+ : isTypeRefused || isNoChange
2244
2303
  ? "reflect-skipped"
2245
2304
  : "reflect-failed",
2246
2305
  result: reflectResult,
2247
2306
  });
2248
- // Cooldown skips, guard rejects, and type-refused skips are not
2249
- // failures — do not pollute recentErrors with them (those get
2250
- // injected as `avoidPatterns` into the next reflect prompt). Guard
2251
- // rejects ARE worth showing the LLM as a learn-signal so the next
2252
- // iteration sees "your last expansion was too large"; type-refused
2253
- // is deterministic and adds no learning signal.
2254
- if (!reflectResult.ok && !isCooldown && !isTypeRefused) {
2307
+ // Cooldown skips, guard rejects, type-refused skips, and noise-gate
2308
+ // skips are not failures — do not pollute recentErrors with them
2309
+ // (those get injected as `avoidPatterns` into the next reflect
2310
+ // prompt). Guard rejects ARE worth showing the LLM as a learn-signal
2311
+ // so the next iteration sees "your last expansion was too large";
2312
+ // type-refused and no-change are deterministic and add no learning
2313
+ // signal.
2314
+ if (!reflectResult.ok && !isCooldown && !isTypeRefused && !isNoChange) {
2255
2315
  const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
2256
2316
  pushRecentError("reflect", errMsg);
2257
2317
  }
@@ -2373,11 +2433,11 @@ async function runImproveLoopStage(args) {
2373
2433
  }
2374
2434
  }
2375
2435
  }
2376
- const distillResult = await distillFn({
2436
+ const distillResult = await withLlmStage("distill", () => distillFn({
2377
2437
  ref: planned.ref,
2378
2438
  ...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
2379
2439
  ...(options.stashDir ? { stashDir: options.stashDir } : {}),
2380
- });
2440
+ }));
2381
2441
  actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
2382
2442
  if (distillResult.outcome === "queued" && distillResult.proposal) {
2383
2443
  const distillGr = await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg);
@@ -2518,7 +2578,9 @@ async function runImprovePostLoopStage(args) {
2518
2578
  };
2519
2579
  }
2520
2580
  // TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
2521
- async function runImproveMaintenancePasses(args) {
2581
+ // Exported for tests (#584/#585 DB-locking regression coverage); production
2582
+ // callers reach it only through akmImprove → runImprovePostLoopStage.
2583
+ export async function runImproveMaintenancePasses(args) {
2522
2584
  const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, } = args;
2523
2585
  if (!primaryStashDir)
2524
2586
  return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
@@ -2537,8 +2599,27 @@ async function runImproveMaintenancePasses(args) {
2537
2599
  let graphExtractionDurationMs = 0;
2538
2600
  let orphansPurged = 0;
2539
2601
  let proposalsExpired = 0;
2602
+ const openIndexDb = () => openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2603
+ // #584: reindexFn opens its own write handle on the same index.db WAL file.
2604
+ // Holding our handle across that call produced SQLITE_BUSY / "database is
2605
+ // locked" failures in production, so the handle is closed BEFORE every
2606
+ // reindex and reopened after — the fresh handle also sees the post-reindex
2607
+ // state that graph extraction and staleness detection below rely on. The
2608
+ // reopen runs in `finally` so a failed reindex still leaves a usable handle.
2609
+ const reindexWithIndexDbReleased = async (stashDir) => {
2610
+ if (db) {
2611
+ closeDatabase(db);
2612
+ db = undefined;
2613
+ }
2614
+ try {
2615
+ await reindexFn({ stashDir });
2616
+ }
2617
+ finally {
2618
+ db = openIndexDb();
2619
+ }
2620
+ };
2540
2621
  try {
2541
- db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2622
+ db = openIndexDb();
2542
2623
  // Memory inference candidate-discovery (post-Item 9 fix from
2543
2624
  // memory:akm-improve-critical-review-2026-05-20). Previously this pass
2544
2625
  // was gated on memoryRefsForInference.size > 0 AND passed those refs as a
@@ -2564,7 +2645,7 @@ async function runImproveMaintenancePasses(args) {
2564
2645
  const inferenceStart = Date.now();
2565
2646
  try {
2566
2647
  // O-1 (#364): pass budget signal so a hung inference call is cancelled.
2567
- memoryInference = await memoryInferenceFn({
2648
+ memoryInference = await withLlmStage("memory-inference", () => memoryInferenceFn({
2568
2649
  config,
2569
2650
  sources,
2570
2651
  signal: budgetSignal,
@@ -2574,7 +2655,7 @@ async function runImproveMaintenancePasses(args) {
2574
2655
  const current = event.currentRef ? ` ${event.currentRef}` : "";
2575
2656
  info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
2576
2657
  },
2577
- });
2658
+ }));
2578
2659
  memoryInferenceDurationMs = Date.now() - inferenceStart;
2579
2660
  actions.push({ ref: "memory:_inference", mode: "memory-inference", result: memoryInference });
2580
2661
  info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
@@ -2587,7 +2668,7 @@ async function runImproveMaintenancePasses(args) {
2587
2668
  if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
2588
2669
  info("[improve] reindexing after memory inference writes");
2589
2670
  try {
2590
- await reindexFn({ stashDir: primaryStashDir });
2671
+ await reindexWithIndexDbReleased(primaryStashDir);
2591
2672
  reindexedAfterInference = true;
2592
2673
  info("[improve] reindex after memory inference complete");
2593
2674
  }
@@ -2623,7 +2704,7 @@ async function runImproveMaintenancePasses(args) {
2623
2704
  if (consolidationRan && !reindexedAfterInference) {
2624
2705
  info("[improve] reindexing after consolidation (graph extraction needs current state)");
2625
2706
  try {
2626
- await reindexFn({ stashDir: primaryStashDir });
2707
+ await reindexWithIndexDbReleased(primaryStashDir);
2627
2708
  reindexedAfterInference = true;
2628
2709
  info("[improve] reindex after consolidation complete");
2629
2710
  }
@@ -2631,10 +2712,8 @@ async function runImproveMaintenancePasses(args) {
2631
2712
  allWarnings.push(`reindex after consolidation failed: ${err instanceof Error ? err.message : String(err)}`);
2632
2713
  }
2633
2714
  }
2634
- if (db && reindexedAfterInference) {
2635
- closeDatabase(db);
2636
- db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2637
- }
2715
+ // #584: no close/reopen needed here — reindexWithIndexDbReleased
2716
+ // already swapped in a fresh post-reindex handle.
2638
2717
  // Resolve touched refs to absolute file paths. Skipped for fullScan
2639
2718
  // (candidatePaths stays undefined → extractor processes all files).
2640
2719
  let candidatePaths;
@@ -2654,7 +2733,7 @@ async function runImproveMaintenancePasses(args) {
2654
2733
  info(`[improve] graph extraction ${event.processed}/${event.total}${current} (extracted ${event.extracted}, entities ${event.totalEntities}, relations ${event.totalRelations})`);
2655
2734
  };
2656
2735
  // O-1 (#364): pass budget signal so a hung graph extraction call is cancelled.
2657
- graphExtraction = await graphExtractionFn({
2736
+ graphExtraction = await withLlmStage("graph-extraction", () => graphExtractionFn({
2658
2737
  config,
2659
2738
  sources,
2660
2739
  signal: budgetSignal,
@@ -2662,7 +2741,7 @@ async function runImproveMaintenancePasses(args) {
2662
2741
  reEnrich: false,
2663
2742
  onProgress: progressHandler,
2664
2743
  options: { candidatePaths },
2665
- });
2744
+ }));
2666
2745
  graphExtractionDurationMs = Date.now() - extractionStart;
2667
2746
  actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
2668
2747
  info(`[improve] graph extraction complete (${graphExtraction.quality.extractedFiles} files, ${graphExtraction.quality.entityCount} entities, ${graphExtraction.quality.relationCount} relations)`);
@@ -2733,18 +2812,22 @@ async function runImproveMaintenancePasses(args) {
2733
2812
  // invocation, and every command surface emits at least one event besides —
2734
2813
  // without this trim, state.db is a permanent append-only log. Config key
2735
2814
  // `improve.eventRetentionDays` (default 90, set 0 to disable) controls the
2736
- // window. `purgeOldEvents()` opens its own state.db handle separate from
2737
- // the index `db` above (different SQLite file).
2815
+ // window. The purge runs against state.db (a different SQLite file from
2816
+ // the index `db` above).
2738
2817
  {
2739
2818
  const retentionDays = typeof config.improve?.eventRetentionDays === "number" ? config.improve.eventRetentionDays : 90;
2740
2819
  if (retentionDays > 0) {
2820
+ // #585: reuse the long-lived eventsCtx.db connection when akmImprove
2821
+ // opened one — opening a second state.db write connection while
2822
+ // eventsDb is still live made two simultaneous writers contend on the
2823
+ // same WAL file ("database is locked"). Only the eventsCtx.dbPath
2824
+ // fallback path (state.db failed to open up-front) opens — and then
2825
+ // owns and closes — its own handle. C2 still holds: the fallback uses
2826
+ // the boundary-pinned path, never a live `process.env` re-read.
2827
+ const ownsStateDb = !eventsCtx?.db;
2741
2828
  let stateDb;
2742
2829
  try {
2743
- // C2: reuse the boundary-pinned state.db path carried on eventsCtx so
2744
- // this purge open never re-reads `process.env` live mid-run. The path
2745
- // is always set by akmImprove; openStateDatabase() falls back to the
2746
- // env-derived default only if a caller omitted it entirely.
2747
- stateDb = openStateDatabase(eventsCtx?.dbPath);
2830
+ stateDb = eventsCtx?.db ?? openStateDatabase(eventsCtx?.dbPath);
2748
2831
  const purgedCount = purgeOldEvents(stateDb, retentionDays);
2749
2832
  if (purgedCount > 0) {
2750
2833
  info(`[improve] events purge: ${purgedCount} event(s) older than ${retentionDays}d removed from state.db`);
@@ -2772,7 +2855,7 @@ async function runImproveMaintenancePasses(args) {
2772
2855
  allWarnings.push(`events purge failed: ${err instanceof Error ? err.message : String(err)}`);
2773
2856
  }
2774
2857
  finally {
2775
- if (stateDb) {
2858
+ if (ownsStateDb && stateDb) {
2776
2859
  try {
2777
2860
  stateDb.close();
2778
2861
  }
@@ -2781,6 +2864,37 @@ async function runImproveMaintenancePasses(args) {
2781
2864
  }
2782
2865
  }
2783
2866
  }
2867
+ // task_logs in logs.db (#579) shares the same retention window as
2868
+ // events/improve_runs — all three are observability data governed by
2869
+ // the single improve.eventRetentionDays knob. Separate try/finally
2870
+ // because logs.db is a different file: a locked/missing logs.db must
2871
+ // not block the state.db purges above.
2872
+ let logsDb;
2873
+ try {
2874
+ logsDb = openLogsDatabase();
2875
+ const taskLogsPurged = purgeOldTaskLogs(logsDb, retentionDays);
2876
+ if (taskLogsPurged > 0) {
2877
+ info(`[improve] task_logs purge: ${taskLogsPurged} log line(s) older than ${retentionDays}d removed from logs.db`);
2878
+ }
2879
+ appendEvent({
2880
+ eventType: "task_logs_purged",
2881
+ ref: "task_logs:_purge",
2882
+ metadata: { purgedCount: taskLogsPurged, retentionDays },
2883
+ }, eventsCtx);
2884
+ }
2885
+ catch (err) {
2886
+ allWarnings.push(`task_logs purge failed: ${err instanceof Error ? err.message : String(err)}`);
2887
+ }
2888
+ finally {
2889
+ if (logsDb) {
2890
+ try {
2891
+ logsDb.close();
2892
+ }
2893
+ catch {
2894
+ // best-effort
2895
+ }
2896
+ }
2897
+ }
2784
2898
  }
2785
2899
  }
2786
2900
  // Phase 4A (staleness detection). Activates the `deprecated` belief-state
@@ -2789,7 +2903,7 @@ async function runImproveMaintenancePasses(args) {
2789
2903
  // and before the URL check (which lives in the outer caller).
2790
2904
  if (sources.length > 0) {
2791
2905
  try {
2792
- stalenessDetection = await stalenessDetectionFn({ config, sources, signal: budgetSignal, db });
2906
+ stalenessDetection = await withLlmStage("staleness-detection", () => stalenessDetectionFn({ config, sources, signal: budgetSignal, db }));
2793
2907
  if (stalenessDetection.considered > 0) {
2794
2908
  info(`[improve] staleness detection complete (considered ${stalenessDetection.considered}, ` +
2795
2909
  `deprecated ${stalenessDetection.deprecated}, confirmed ${stalenessDetection.confirmed}, ` +
@@ -46,6 +46,7 @@ import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigF
46
46
  import { checkReflectSize } from "../proposal/validators/proposal-quality-validators.js";
47
47
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
48
48
  import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
49
+ import { classifyReflectChange } from "./reflect-noise.js";
49
50
  const MAX_FEEDBACK_LINES = 10;
50
51
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
51
52
  /**
@@ -1138,6 +1139,30 @@ export async function akmReflect(options = {}) {
1138
1139
  content: sanitizeOutcome.content,
1139
1140
  ...(sanitizeOutcome.frontmatter ? { frontmatter: sanitizeOutcome.frontmatter } : {}),
1140
1141
  };
1142
+ // 7c. Noise gate (#580): never queue a proposal whose sanitized content is
1143
+ // identical to the current asset (empty diff) or differs only cosmetically
1144
+ // (whitespace reflow, code-fence language hints, YAML scalar re-folding).
1145
+ // Pure deterministic text comparison — see `reflect-noise.ts`. Runs before
1146
+ // the draftMode branch so self-consistency sampling never votes a no-op
1147
+ // candidate into the queue either. Skipped when there is no source asset
1148
+ // (new-asset proposals have nothing to diff against).
1149
+ if (assetContent !== undefined) {
1150
+ const changeKind = classifyReflectChange(assetContent, payload.content);
1151
+ if (changeKind !== "substantive") {
1152
+ const subreason = changeKind === "noop" ? "reflect_skipped_noop" : "reflect_skipped_cosmetic";
1153
+ emitReflectFailed("no_change", subreason, options.ref, { changeKind });
1154
+ return {
1155
+ schemaVersion: 1,
1156
+ ok: false,
1157
+ reason: "no_change",
1158
+ error: changeKind === "noop"
1159
+ ? `Reflect skipped: proposed content for ${payload.ref} is identical to the current asset (empty diff); no proposal created.`
1160
+ : `Reflect skipped: proposed content for ${payload.ref} is a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes); no proposal created.`,
1161
+ ...(options.ref ? { ref: options.ref } : {}),
1162
+ exitCode: result.exitCode,
1163
+ };
1164
+ }
1165
+ }
1141
1166
  // 8. Create the proposal. The proposal queue is the ONLY thing reflect
1142
1167
  // writes — promotion to a real asset is gated by `akm proposal accept`.
1143
1168
  //