akm-cli 0.9.16 → 0.9.17-alpha.1

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 (46) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/dist/assets/prompts/consolidate-system.md +4 -11
  3. package/dist/assets/prompts/graph-extract-user-prompt.md +5 -5
  4. package/dist/commands/health/accept-rate.js +6 -0
  5. package/dist/commands/health/checks.js +54 -0
  6. package/dist/commands/health/improve-metrics.js +1 -5
  7. package/dist/commands/health/report-view-model.js +0 -1
  8. package/dist/commands/health.js +10 -0
  9. package/dist/commands/improve/consolidate/chunking.js +19 -35
  10. package/dist/commands/improve/consolidate/merge.js +6 -9
  11. package/dist/commands/improve/consolidate.js +104 -91
  12. package/dist/commands/improve/distill/promote-memory.js +40 -2
  13. package/dist/commands/improve/distill/quality-gate.js +186 -23
  14. package/dist/commands/improve/distill.js +42 -8
  15. package/dist/commands/improve/eligibility.js +13 -3
  16. package/dist/commands/improve/improve-cli.js +32 -9
  17. package/dist/commands/improve/improve-strategies.js +23 -1
  18. package/dist/commands/improve/improve.js +121 -84
  19. package/dist/commands/improve/loop-stages.js +241 -108
  20. package/dist/commands/improve/preparation.js +50 -17
  21. package/dist/commands/improve/reflect.js +16 -5
  22. package/dist/commands/improve/shared.js +0 -10
  23. package/dist/commands/proposal/drain.js +79 -10
  24. package/dist/commands/proposal/proposal-types.js +21 -0
  25. package/dist/commands/proposal/repository.js +108 -29
  26. package/dist/core/asset/frontmatter.js +106 -1
  27. package/dist/core/config/schema/improve-processes.js +29 -2
  28. package/dist/core/improve-result.js +9 -0
  29. package/dist/core/paths.js +7 -0
  30. package/dist/indexer/ensure-index.js +52 -7
  31. package/dist/indexer/graph/graph-extraction.js +82 -8
  32. package/dist/indexer/passes/memory-inference.js +16 -1
  33. package/dist/llm/client.js +16 -2
  34. package/dist/llm/graph-extract.js +162 -18
  35. package/dist/scripts/akm-migrate-node.js +20 -4
  36. package/dist/scripts/akm-migrate.js +20 -4
  37. package/dist/storage/repositories/index-entries-repository.js +43 -0
  38. package/dist/storage/repositories/proposals-repository.js +4 -1
  39. package/dist/storage/state-db-integrity.js +123 -0
  40. package/dist/workflows/program/schema.js +1 -0
  41. package/docs/reference/cli.md +4 -3
  42. package/docs/reference/data-and-telemetry.md +1 -0
  43. package/package.json +1 -1
  44. package/schemas/akm-config.json +44 -0
  45. package/schemas/akm-workflow.json +1 -0
  46. package/dist/commands/improve/eval-cases.js +0 -52
@@ -18,11 +18,13 @@ import { info, warn, warnVerbose } from "../../core/warn.js";
18
18
  import { beginWriteProvenance, relativeWrittenPath } from "../../core/write-provenance.js";
19
19
  import { resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
20
20
  import { ensureIndex } from "../../indexer/ensure-index.js";
21
+ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
21
22
  import { akmIndex } from "../../indexer/indexer.js";
22
23
  import { collectPendingMemories } from "../../indexer/passes/memory-inference.js";
23
24
  import { resolveEntryContentDir, resolveSourceEntries } from "../../indexer/search/search-source.js";
24
25
  import { collectEngineCredentialValues } from "../../integrations/agent/engine-resolution.js";
25
26
  import { installLlmUsagePersistence, LLM_USAGE_EVENT } from "../../llm/usage-persist.js";
27
+ import { withLlmStage } from "../../llm/usage-telemetry.js";
26
28
  import { isGitBackedStash, listGitChangedPaths, resolveWritableOverride, saveGitStash, } from "../../sources/providers/git.js";
27
29
  import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
28
30
  import { getEntryCount } from "../../storage/repositories/index-entries-repository.js";
@@ -34,7 +36,6 @@ import { describeGatedLanes, isAutonomyLaneAllowed } from "./autonomy-gate.js";
34
36
  import { akmDistill } from "./distill.js";
35
37
  // Eligibility / candidate-selection predicates live in ./eligibility.
36
38
  import { buildLatestProposalTsMap, collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
37
- import { countEvalCases } from "./eval-cases.js";
38
39
  import { projectResolvedProcessRouting, resolveImprovePlan, resolveImproveStrategy, shouldSkipRef, } from "./improve-strategies.js";
39
40
  import { buildImproveUsageReport } from "./improve-usage-report.js";
40
41
  import { improveLockPath, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
@@ -149,6 +150,7 @@ export async function akmImprove(options = {}) {
149
150
  let autonomyGatedDirectLanes = [];
150
151
  let guidance;
151
152
  let triageDrain;
153
+ let ensureIndexDurationMs;
152
154
  let improveLockOwnership;
153
155
  let exitBackstop;
154
156
  // I1: open a single state.db connection for the main improve run so all
@@ -205,13 +207,33 @@ export async function akmImprove(options = {}) {
205
207
  // #652: open AFTER the lock, so the journal covers exactly the window in
206
208
  // which this run — and only this run — is allowed to write.
207
209
  runJournal.open();
208
- // Drain the standing proposal backlog before indexing so fresh proposal
209
- // generation sees promotions from this same serialized run.
210
+ // R6: ensureIndex BEFORE triage, not after. Triage promotes proposals
211
+ // straight into the flat `knowledge/` root; running the blocking
212
+ // reindex only afterward (inside indexAndCollect, below) meant every
213
+ // triage promotion dirtied the stash right before the rescan that is
214
+ // supposed to precede it, so the "blocking" reindex always found fresh
215
+ // work and paid for a full walk on the very run it was meant to avoid.
216
+ const bootstrap = await runIndexBootstrapPass(setup, budgetAbortController.signal);
217
+ preEnsureCleanupWarnings.push(...bootstrap.warnings);
218
+ ensureIndexDurationMs = bootstrap.ensureIndexDurationMs;
219
+ // Drain the standing proposal backlog under the now-current index, so
220
+ // fresh proposal generation sees promotions from this same serialized
221
+ // run.
210
222
  triageDrain = await runTriagePrePass(setup);
223
+ // R6: index triage's own writes incrementally (the R8
224
+ // `indexWrittenAssets` pattern) instead of letting them sit newer than
225
+ // `builtAt` until the next full reindex. `collectEligibleRefs` below
226
+ // still needs to see them, and per-file staleness (`ensure-index.ts`)
227
+ // needs their `content_hash` recorded so it does not re-trigger a full
228
+ // rescan next run for content it already has.
229
+ if (setup.primaryStashDir) {
230
+ const triageWrittenPaths = runJournal.current()?.writtenPaths() ?? [];
231
+ if (triageWrittenPaths.length > 0) {
232
+ await indexWrittenAssets(setup.primaryStashDir, triageWrittenPaths);
233
+ }
234
+ }
211
235
  }
212
- // #339 fix: ensureIndex MUST run BEFORE collectEligibleRefs (inside the
213
- // indexAndCollect helper).
214
- const collected = await indexAndCollect({ run: setup, signal: budgetAbortController.signal });
236
+ const collected = await indexAndCollect({ run: setup });
215
237
  plannedRefs = collected.plannedRefs;
216
238
  memorySummary = collected.memorySummary;
217
239
  strategyFilteredRefs = collected.strategyFilteredRefs;
@@ -219,7 +241,6 @@ export async function akmImprove(options = {}) {
219
241
  memoryCleanupPlan = collected.memoryCleanupPlan;
220
242
  autonomyGatedDirectLanes = collected.autonomyGatedDirectLanes;
221
243
  guidance = collected.guidance;
222
- preEnsureCleanupWarnings.push(...collected.warnings);
223
244
  if (options.dryRun) {
224
245
  const result = await runDryPlanningStage({
225
246
  run: setup,
@@ -291,6 +312,7 @@ export async function akmImprove(options = {}) {
291
312
  rawPlannedRefs: plannedRefs,
292
313
  indexSnapshot,
293
314
  triageDrain,
315
+ ensureIndexDurationMs,
294
316
  eventsCtx,
295
317
  });
296
318
  // End-of-run BATCH auto-sync — the converged commit. Recognition is
@@ -620,83 +642,97 @@ function recordImproveFailure(err, run, eventsCtx) {
620
642
  }, eventsCtx);
621
643
  }
622
644
  /**
623
- * ensureIndex + collectEligibleRefs + the contradiction pre-pass + the
624
- * memory-cleanup recompute (#339 ordering). Formerly the runIndexAndCollect
625
- * closure mutating outer `let`s; now a pure pass returning its results.
645
+ * R6: ensureIndex bootstrap pass, hoisted out of `indexAndCollect` (below) so
646
+ * the caller can run it BEFORE the triage pre-pass instead of after it —
647
+ * triage promotes proposals straight into `knowledge/`, so ensureIndex must
648
+ * see a current index before triage dirties it, not after. Gated exactly as
649
+ * this logic was gated inside `indexAndCollect`: only for a resolved
650
+ * `primaryStashDir` on a non-dry-run.
651
+ *
652
+ * #339 fix carried over unchanged: ensureIndex MUST run before
653
+ * collectEligibleRefs. The eligible-ref query reads the `entries` table; if a
654
+ * DB version upgrade just dropped that table (or the index is otherwise
655
+ * empty), skipping this would silently return plannedRefs=[] and the improve
656
+ * loop would no-op.
626
657
  */
627
- async function indexAndCollect(args) {
628
- const { signal } = args;
629
- const { scope, options, primaryStashDir, improveProfile, _earlyConfig, ensureIndexFn, collectEligibleRefsImpl } = args.run;
658
+ async function runIndexBootstrapPass(run, signal) {
659
+ const { primaryStashDir, options, ensureIndexFn } = run;
630
660
  const warnings = [];
631
- let plannedRefs = [];
632
- let memorySummary = { eligible: 0, derived: 0 };
633
- let strategyFilteredRefs = [];
634
- let indexSnapshot;
635
- // #339 fix: ensureIndex MUST run BEFORE collectEligibleRefs. The eligible-ref
636
- // query reads the `entries` table; if a DB version upgrade just dropped that
637
- // table (or the index is otherwise empty), the prior run order silently
638
- // returned plannedRefs=[] and the improve loop no-op'd. Hoisting the call
639
- // here repopulates the index first so the subsequent query sees fresh data.
640
- if (primaryStashDir && !options.dryRun) {
641
- // Probe pre-ensureIndex entry count to drive the loud-fail warning below.
642
- // Best-effort: a missing DB / unreadable schema is the fresh-install case
643
- // and not a bug — we silently skip the probe.
644
- let preEnsureEntryCount;
645
- try {
646
- const dbPath = getDbPath();
647
- if (fs.existsSync(dbPath)) {
648
- const probeDb = openExistingDatabase();
649
- try {
650
- preEnsureEntryCount = getEntryCount(probeDb);
651
- }
652
- finally {
653
- closeDatabase(probeDb);
654
- }
661
+ if (!primaryStashDir || options.dryRun)
662
+ return { warnings };
663
+ // Probe pre-ensureIndex entry count to drive the loud-fail warning below.
664
+ // Best-effort: a missing DB / unreadable schema is the fresh-install case
665
+ // and not a bug — we silently skip the probe.
666
+ let preEnsureEntryCount;
667
+ try {
668
+ const dbPath = getDbPath();
669
+ if (fs.existsSync(dbPath)) {
670
+ const probeDb = openExistingDatabase();
671
+ try {
672
+ preEnsureEntryCount = getEntryCount(probeDb);
673
+ }
674
+ finally {
675
+ closeDatabase(probeDb);
655
676
  }
656
677
  }
657
- catch (err) {
658
- rethrowIfTestIsolationError(err);
659
- // best-effort; leave preEnsureEntryCount undefined
660
- }
678
+ }
679
+ catch (err) {
680
+ rethrowIfTestIsolationError(err);
681
+ // best-effort; leave preEnsureEntryCount undefined
682
+ }
683
+ let ensureIndexDurationMs;
684
+ try {
685
+ await ensureIndexFn(primaryStashDir, {
686
+ mode: "blocking",
687
+ signal,
688
+ // R6: capture the implicit reindex's wall-clock cost (otherwise
689
+ // discarded) so the caller can surface it on the improve result.
690
+ onReindexTiming: ({ durationMs }) => {
691
+ ensureIndexDurationMs = durationMs;
692
+ },
693
+ });
694
+ }
695
+ catch (err) {
696
+ if (signal.aborted)
697
+ throw err;
698
+ warnings.push(`ensureIndex failed: ${errMessage(err)}`);
699
+ }
700
+ // #339 loud-fail: if the index was empty pre-ensureIndex but is now
701
+ // populated, a version-upgrade-triggered rebuild just happened. Surface
702
+ // that on stderr so the improve run is not silently masked by stale
703
+ // index state. Zero-before AND zero-after is the empty-stash case and
704
+ // is intentionally not warned (not a bug).
705
+ if (preEnsureEntryCount === 0) {
661
706
  try {
662
- await ensureIndexFn(primaryStashDir, { mode: "blocking", signal: signal });
663
- }
664
- catch (err) {
665
- if (signal.aborted)
666
- throw err;
667
- warnings.push(`ensureIndex failed: ${errMessage(err)}`);
668
- }
669
- // #339 loud-fail: if the index was empty pre-ensureIndex but is now
670
- // populated, a version-upgrade-triggered rebuild just happened. Surface
671
- // that on stderr so the improve run is not silently masked by stale
672
- // index state. Zero-before AND zero-after is the empty-stash case and
673
- // is intentionally not warned (not a bug).
674
- if (preEnsureEntryCount === 0) {
707
+ const probeDb = openExistingDatabase();
708
+ let postCount = 0;
675
709
  try {
676
- const probeDb = openExistingDatabase();
677
- let postCount = 0;
678
- try {
679
- postCount = getEntryCount(probeDb);
680
- }
681
- finally {
682
- closeDatabase(probeDb);
683
- }
684
- if (postCount > 0) {
685
- warn("[improve] index was empty after DB version upgrade — repopulating before continuing");
686
- }
710
+ postCount = getEntryCount(probeDb);
687
711
  }
688
- catch (err) {
689
- rethrowIfTestIsolationError(err);
690
- // best-effort
712
+ finally {
713
+ closeDatabase(probeDb);
714
+ }
715
+ if (postCount > 0) {
716
+ warn("[improve] index was empty after DB version upgrade — repopulating before continuing");
691
717
  }
692
718
  }
719
+ catch (err) {
720
+ rethrowIfTestIsolationError(err);
721
+ // best-effort
722
+ }
693
723
  }
694
- ({
695
- plannedRefs,
696
- memorySummary,
697
- strategyFilteredRefs = [],
698
- indexSnapshot,
699
- } = await collectEligibleRefsImpl(scope, options.stashDir, improveProfile, _earlyConfig));
724
+ return { warnings, ensureIndexDurationMs };
725
+ }
726
+ /**
727
+ * collectEligibleRefs + the contradiction pre-pass + the memory-cleanup
728
+ * recompute. Formerly the runIndexAndCollect closure mutating outer `let`s;
729
+ * now a pure pass returning its results. The ensureIndex bootstrap this
730
+ * function used to open with now runs earlier, before triage — see
731
+ * {@link runIndexBootstrapPass} and its call site in `akmImprove`.
732
+ */
733
+ async function indexAndCollect(args) {
734
+ const { scope, options, primaryStashDir, improveProfile, _earlyConfig, collectEligibleRefsImpl } = args.run;
735
+ const { plannedRefs, memorySummary, strategyFilteredRefs = [], indexSnapshot, } = await collectEligibleRefsImpl(scope, options.stashDir, improveProfile, _earlyConfig);
700
736
  const cleanupParentRef = memoryCleanupParentRef(scope, options.stashDir);
701
737
  // D8 — the two direct lanes share one eligibility predicate, which reads scope
702
738
  // and eligible-memory count and no strategy flag at all. Compute it once: it
@@ -724,7 +760,6 @@ async function indexAndCollect(args) {
724
760
  memoryCleanupPlan,
725
761
  autonomyGatedDirectLanes,
726
762
  guidance,
727
- warnings,
728
763
  };
729
764
  }
730
765
  /** The P2 lock-skipped envelope (graceful `skipIfLocked` early return). Exported for unit tests. */
@@ -810,6 +845,7 @@ export function buildDryRunResult(run, collected, preparation) {
810
845
  : {}),
811
846
  ...(strategyFilteredRefs.length > 0 ? { strategyFilteredRefs } : {}),
812
847
  ...(run.resolvedPlan.engineUnavailable.length > 0 ? { skippedProcesses: run.resolvedPlan.engineUnavailable } : {}),
848
+ ...(run.options.engineProbe !== undefined ? { engineProbe: run.options.engineProbe } : {}),
813
849
  ...(preparation?.proactiveMaintenance ? { proactiveMaintenance: preparation.proactiveMaintenance } : {}),
814
850
  };
815
851
  }
@@ -935,7 +971,7 @@ async function runTriagePrePass(run) {
935
971
  const policy = resolveDrainPolicy(triageConfig?.policy);
936
972
  const applyMode = triageConfig?.applyMode ?? "queue";
937
973
  const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
938
- triageDrain = await drainProposalsFn({
974
+ triageDrain = await withLlmStage("triage", () => drainProposalsFn({
939
975
  stashDir: primaryStashDir,
940
976
  ...(options.target ? { target: options.target } : {}),
941
977
  config: options.config,
@@ -946,7 +982,7 @@ async function runTriagePrePass(run) {
946
982
  excludeIds: new Set(),
947
983
  ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
948
984
  judgment: resolvedPlan.triageJudgment,
949
- });
985
+ }), { engine: resolvedPlan.triageJudgment?.engine, process: "triage.judgment" });
950
986
  }
951
987
  catch (err) {
952
988
  warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
@@ -1123,7 +1159,7 @@ export function refilterProactiveLoopRefs(loopRefs, improveProfile) {
1123
1159
  * `runImproveStageSequence` — fn-size decomposition, no logic change).
1124
1160
  */
1125
1161
  async function runPostLoopStageOrSkip(args) {
1126
- const { budgetAbortController, scope, options, primaryStashDir, preparation, memoryRefsForInference, reindexFn, eventsCtx, improveProfile, resolvedPlan, runImprovePostLoopStageImpl, } = args;
1162
+ const { budgetAbortController, scope, options, primaryStashDir, preparation, memoryRefsForInference, eventsCtx, improveProfile, resolvedPlan, runImprovePostLoopStageImpl, } = args;
1127
1163
  // Do not start new post-loop work after the shared wall-clock budget. The
1128
1164
  // result still finalizes normally, so scheduled budget exhaustion exits 0.
1129
1165
  const emptyPostLoopResult = {
@@ -1144,7 +1180,6 @@ async function runPostLoopStageOrSkip(args) {
1144
1180
  appliedCleanup: preparation.appliedCleanup,
1145
1181
  cleanupWarnings: preparation.cleanupWarnings,
1146
1182
  memoryRefsForInference,
1147
- reindexFn,
1148
1183
  eventsCtx,
1149
1184
  budgetSignal: budgetAbortController.signal,
1150
1185
  improveProfile,
@@ -1283,7 +1318,6 @@ async function runImproveStageSequence(args) {
1283
1318
  primaryStashDir,
1284
1319
  preparation,
1285
1320
  memoryRefsForInference,
1286
- reindexFn,
1287
1321
  eventsCtx,
1288
1322
  improveProfile,
1289
1323
  resolvedPlan,
@@ -1343,8 +1377,8 @@ async function runImproveStageSequence(args) {
1343
1377
  * side effects run between assembly and emit).
1344
1378
  */
1345
1379
  function finalizeImproveResult(args) {
1346
- const { guidance, memorySummary, memoryCleanupPlan, strategyFilteredRefs, rawPlannedRefs, indexSnapshot, triageDrain, eventsCtx, } = args;
1347
- const { selectedStrategy, scope, options, primaryStashDir, startMs, resolvedPlan } = args.run;
1380
+ const { guidance, memorySummary, memoryCleanupPlan, strategyFilteredRefs, rawPlannedRefs, indexSnapshot, triageDrain, ensureIndexDurationMs, eventsCtx, } = args;
1381
+ const { selectedStrategy, scope, options, startMs, resolvedPlan } = args.run;
1348
1382
  const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, deadUrlCoverage, finalActions, } = args.seq;
1349
1383
  // C1 (13-bus-factor): fold the per-ref `distill-skipped` rows (~13k/run,
1350
1384
  // ~91% of result_json bytes) into a bounded aggregate BEFORE persistence.
@@ -1416,6 +1450,7 @@ function finalizeImproveResult(args) {
1416
1450
  : {}),
1417
1451
  ...(strategyFilteredRefs.length > 0 ? { strategyFilteredRefs } : {}),
1418
1452
  ...(resolvedPlan.engineUnavailable.length > 0 ? { skippedProcesses: resolvedPlan.engineUnavailable } : {}),
1453
+ ...(options.engineProbe !== undefined ? { engineProbe: options.engineProbe } : {}),
1419
1454
  ...(usageReport ? { usageReport } : {}),
1420
1455
  actions: persistedActions,
1421
1456
  ...(distillSkippedAggregate ? { distillSkipped: distillSkippedAggregate } : {}),
@@ -1426,7 +1461,6 @@ function finalizeImproveResult(args) {
1426
1461
  ...(preparation.memoryIndexHealth !== undefined ? { memoryIndexHealth: preparation.memoryIndexHealth } : {}),
1427
1462
  ...(preparation.coverageGaps.length > 0 ? { coverageGaps: preparation.coverageGaps } : {}),
1428
1463
  ...(preparation.extract && preparation.extract.length > 0 ? { extract: preparation.extract } : {}),
1429
- ...(primaryStashDir !== undefined ? { evalCasesWritten: countEvalCases(primaryStashDir) } : {}),
1430
1464
  ...(deadUrls !== undefined && deadUrls.length > 0 ? { deadUrls } : {}),
1431
1465
  // Present whenever the check ran, unlike `deadUrls` above — a clean run
1432
1466
  // (zero dead links) still needs to tell the health report how much of
@@ -1449,6 +1483,10 @@ function finalizeImproveResult(args) {
1449
1483
  // `/tmp/akm-health-investigations/metrics-taxonomy-review.md` §1k / §3.
1450
1484
  ...(memoryInferenceDurationMs > 0 ? { memoryInferenceDurationMs } : {}),
1451
1485
  ...(graphExtractionDurationMs > 0 ? { graphExtractionDurationMs } : {}),
1486
+ // R6: the start-of-run implicit reindex's wall-clock cost, when one ran
1487
+ // (absent — not zero — when the index was already fresh and no inline
1488
+ // rebuild was needed).
1489
+ ...(ensureIndexDurationMs !== undefined ? { ensureIndexDurationMs } : {}),
1452
1490
  ...(cycleMetrics ? { cycleMetrics } : {}),
1453
1491
  ...(orphansPurged !== undefined ? { orphansPurged } : {}),
1454
1492
  ...(proposalsExpired !== undefined && proposalsExpired > 0 ? { proposalsExpired } : {}),
@@ -1606,7 +1644,6 @@ function emitImproveCompletedEvent(result, durations, eventsCtx) {
1606
1644
  noopActions: classCounts.noop,
1607
1645
  reflectsWithErrorContext: result.reflectsWithErrorContext ?? 0,
1608
1646
  coverageGapCount: result.coverageGaps?.length ?? 0,
1609
- evalCasesWritten: result.evalCasesWritten ?? 0,
1610
1647
  deadUrlCount: result.deadUrls?.length ?? 0,
1611
1648
  deadUrlsChecked: result.deadUrlCoverage?.checked ?? 0,
1612
1649
  deadUrlsTotal: result.deadUrlCoverage?.total ?? 0,