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

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 (120) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +2079 -608
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/fact-linter.js +39 -0
  55. package/dist/commands/lint/index.js +31 -13
  56. package/dist/commands/lint/memory-linter.js +1 -1
  57. package/dist/commands/lint/registry.js +7 -2
  58. package/dist/commands/lint/task-linter.js +3 -3
  59. package/dist/commands/lint/workflow-linter.js +26 -1
  60. package/dist/commands/proposal/drain.js +73 -6
  61. package/dist/commands/proposal/proposal-cli.js +22 -10
  62. package/dist/commands/proposal/proposal.js +17 -1
  63. package/dist/commands/proposal/validators/proposals.js +369 -329
  64. package/dist/commands/read/curate.js +344 -80
  65. package/dist/commands/read/search-cli.js +7 -0
  66. package/dist/commands/read/search.js +1 -0
  67. package/dist/commands/read/show.js +67 -2
  68. package/dist/commands/remember.js +6 -2
  69. package/dist/commands/sources/installed-stashes.js +5 -1
  70. package/dist/commands/sources/stash-cli.js +10 -2
  71. package/dist/core/asset/asset-registry.js +2 -0
  72. package/dist/core/asset/asset-spec.js +14 -0
  73. package/dist/core/asset/frontmatter.js +166 -167
  74. package/dist/core/asset/markdown.js +8 -0
  75. package/dist/core/config/config-schema.js +255 -2
  76. package/dist/core/config/config.js +2 -2
  77. package/dist/core/logs-db.js +305 -0
  78. package/dist/core/paths.js +3 -0
  79. package/dist/core/state-db.js +706 -42
  80. package/dist/indexer/db/db.js +364 -38
  81. package/dist/indexer/db/graph-db.js +129 -86
  82. package/dist/indexer/ensure-index.js +152 -17
  83. package/dist/indexer/graph/graph-boost.js +51 -41
  84. package/dist/indexer/graph/graph-extraction.js +203 -3
  85. package/dist/indexer/index-writer-lock.js +99 -0
  86. package/dist/indexer/indexer.js +114 -111
  87. package/dist/indexer/passes/memory-inference.js +71 -25
  88. package/dist/indexer/passes/staleness-detect.js +2 -5
  89. package/dist/indexer/search/db-search.js +15 -4
  90. package/dist/indexer/search/ranking-contributors.js +22 -0
  91. package/dist/indexer/search/ranking.js +4 -0
  92. package/dist/indexer/walk/matchers.js +9 -0
  93. package/dist/integrations/agent/prompts.js +1 -0
  94. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  95. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  96. package/dist/integrations/session-logs/index.js +16 -0
  97. package/dist/llm/client.js +38 -4
  98. package/dist/llm/embedder.js +27 -3
  99. package/dist/llm/embedders/local.js +66 -2
  100. package/dist/llm/graph-extract.js +2 -1
  101. package/dist/llm/memory-infer.js +4 -8
  102. package/dist/llm/metadata-enhance.js +9 -1
  103. package/dist/llm/usage-persist.js +77 -0
  104. package/dist/llm/usage-telemetry.js +103 -0
  105. package/dist/output/context.js +3 -2
  106. package/dist/output/html-render.js +73 -0
  107. package/dist/output/renderers.js +73 -1
  108. package/dist/output/shapes/curate.js +14 -2
  109. package/dist/output/shapes/helpers.js +17 -1
  110. package/dist/output/text/helpers.js +78 -1
  111. package/dist/runtime.js +25 -1
  112. package/dist/scripts/migrate-storage.js +1262 -591
  113. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +485 -270
  114. package/dist/sources/providers/tar-utils.js +16 -8
  115. package/dist/storage/sqlite-pragmas.js +146 -0
  116. package/dist/tasks/runner.js +99 -16
  117. package/dist/workflows/db.js +5 -2
  118. package/dist/workflows/validate-summary.js +2 -7
  119. package/docs/data-and-telemetry.md +1 -0
  120. package/package.json +9 -6
@@ -12,6 +12,7 @@ import { stringify as yamlStringify } from "yaml";
12
12
  import { assertNever } from "../core/assert.js";
13
13
  import { AkmError } from "../core/errors.js";
14
14
  import { getOutputMode } from "../output/context.js";
15
+ import { DEFAULT_TEMPLATE, deliverRendered, escapeHtml, renderHtml, resolveTemplatePath } from "../output/html-render.js";
15
16
  import { shapeForCommand } from "../output/shapes.js";
16
17
  import { formatPlain, outputJsonl } from "../output/text.js";
17
18
  // ── Exit codes ───────────────────────────────────────────────────────────────
@@ -111,7 +112,10 @@ export function defineJsonCommand(def) {
111
112
  });
112
113
  }
113
114
  /**
114
- * Render a command result according to the active output mode (json/jsonl/yaml/text).
115
+ * Render a command result according to the active output mode
116
+ * (json/jsonl/yaml/text/md/html). When `--output <path>` is set, the rendered
117
+ * document is written to that file instead of stdout (jsonl excepted — it is
118
+ * a line-streaming protocol and always goes to stdout).
115
119
  */
116
120
  export function output(command, result) {
117
121
  const mode = getOutputMode();
@@ -122,14 +126,14 @@ export function output(command, result) {
122
126
  }
123
127
  switch (mode.format) {
124
128
  case "json":
125
- console.log(JSON.stringify(shaped, null, 2));
129
+ deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
126
130
  return;
127
131
  case "yaml":
128
- console.log(yamlStringify(shaped));
132
+ deliverRendered(yamlStringify(shaped), mode.outputPath);
129
133
  return;
130
134
  case "text": {
131
135
  const plain = formatPlain(command, shaped, mode.detail);
132
- console.log(plain ?? JSON.stringify(shaped, null, 2));
136
+ deliverRendered(plain ?? JSON.stringify(shaped, null, 2), mode.outputPath);
133
137
  return;
134
138
  }
135
139
  case "md":
@@ -137,8 +141,20 @@ export function output(command, result) {
137
141
  // per-run / window-compare table renderings. Commands that don't
138
142
  // implement an md renderer fall back to the JSON envelope so
139
143
  // pipelines never get an empty stdout.
140
- console.log(JSON.stringify(shaped, null, 2));
144
+ deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
141
145
  return;
146
+ case "html": {
147
+ // Generic fallback: render the JSON envelope inside the dark-mode
148
+ // default template. Commands with a bespoke HTML template (`akm health`)
149
+ // intercept before reaching output(), same as the `md` intercept.
150
+ const html = renderHtml(resolveTemplatePath(DEFAULT_TEMPLATE), {
151
+ "%%COMMAND%%": escapeHtml(command),
152
+ "%%CONTENT_JSON%%": escapeHtml(JSON.stringify(shaped, null, 2)),
153
+ "%%GENERATED_AT%%": new Date().toISOString(),
154
+ });
155
+ deliverRendered(html, mode.outputPath);
156
+ return;
157
+ }
142
158
  }
143
159
  }
144
160
  /**
package/dist/cli.js CHANGED
@@ -88,6 +88,7 @@ import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
88
88
  import { plainize } from "./core/tty.js";
89
89
  import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
90
90
  import { getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue } from "./output/context.js";
91
+ import { deliverRendered, renderHtml, resolveTemplatePath } from "./output/html-render.js";
91
92
  import { pkgVersion } from "./version.js";
92
93
  function applyEarlyStderrFlags(argv) {
93
94
  if (argv.includes("--quiet") || argv.includes("-q")) {
@@ -297,16 +298,47 @@ const healthCommand = defineCommand({
297
298
  type: "string",
298
299
  description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
299
300
  },
301
+ compare: {
302
+ type: "string",
303
+ description: "Comparison window for the --format html report's trend deltas (default: 24h)",
304
+ },
300
305
  },
301
306
  async run({ args }) {
302
307
  let resultStatus;
303
- await runWithJsonErrors(() => {
308
+ await runWithJsonErrors(async () => {
304
309
  // citty only surfaces the last value of a repeated flag, so read --windows
305
310
  // directly from argv to support multi-window comparison.
306
311
  const rawWindows = parseAllFlagValues("--windows");
307
312
  const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
308
313
  const groupBy = args["group-by"];
309
314
  const windowCompareRaw = args["window-compare"];
315
+ const mode = getOutputMode();
316
+ // `--format html` is health-specific: render the full HTML health
317
+ // report (charts, KPI cards, advisories) from the bespoke template.
318
+ // Mirrors the `md` intercept below. Two reads, exactly like the
319
+ // retired akm-health-report skill: the canonical per-run window plus a
320
+ // window-compare read for the trend deltas (defaults to 24h,
321
+ // overridable via --compare).
322
+ if (mode.format === "html") {
323
+ // Default the compare window to the report's own `--since` window so the
324
+ // trend deltas are like-for-like (e.g. last 7d vs the prior 7d). A fixed
325
+ // 24h default made a `--since 7d` report compare its 7-day totals against
326
+ // a 24-hour prior window, producing meaningless deltas.
327
+ const compare = args.compare ?? windowCompareRaw ?? args.since ?? "24h";
328
+ const result = akmHealth({ since: args.since, groupBy: "run", windowCompare: compare });
329
+ resultStatus = result.status;
330
+ const deltas = result.deltas;
331
+ const { buildHealthHtmlReplacements } = await import("./commands/health/html-report.js");
332
+ const { listPendingProposals } = await import("./commands/proposal/proposal.js");
333
+ const replacements = buildHealthHtmlReplacements(result, {
334
+ window: args.since ?? "24h",
335
+ compare,
336
+ proposals: listPendingProposals(),
337
+ deltas,
338
+ });
339
+ deliverRendered(renderHtml(resolveTemplatePath("health"), replacements), mode.outputPath);
340
+ return;
341
+ }
310
342
  const result = akmHealth({
311
343
  since: args.since,
312
344
  groupBy: groupBy,
@@ -317,13 +349,12 @@ const healthCommand = defineCommand({
317
349
  // `--format md` is health-specific: render a TSV-shaped per-run or
318
350
  // window-compare table to stdout instead of going through the JSON
319
351
  // envelope. Other modes fall through to the standard output() path.
320
- const mode = getOutputMode();
321
352
  if (mode.format === "md") {
322
353
  if (result.windows && result.windows.length > 0) {
323
- console.log(renderWindowCompareMd(result.windows, result.deltas));
354
+ deliverRendered(renderWindowCompareMd(result.windows, result.deltas), mode.outputPath);
324
355
  }
325
356
  else if (result.runs) {
326
- console.log(renderRunsDetailMd(result.runs));
357
+ deliverRendered(renderRunsDetailMd(result.runs), mode.outputPath);
327
358
  }
328
359
  else {
329
360
  output("health", result);
@@ -419,7 +450,11 @@ export const main = defineCommand({
419
450
  " 78 config error",
420
451
  },
421
452
  args: {
422
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
453
+ format: { type: "string", description: "Output format (json|jsonl|text|yaml|md|html)", default: "json" },
454
+ output: {
455
+ type: "string",
456
+ description: "Write rendered output to a file instead of stdout (all formats except jsonl)",
457
+ },
423
458
  detail: {
424
459
  type: "string",
425
460
  description: "Detail level (verbosity): brief|normal|full. Default: brief.",
@@ -505,6 +540,13 @@ const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
505
540
  // The wrapper sets `AKM_NODE_ENTRY=1` to opt into the startup block. The test
506
541
  // harness never sets it, so importing cli.ts under Bun stays inert as before.
507
542
  if (import.meta.main || process.env.AKM_NODE_ENTRY === "1") {
543
+ // Mark that this process is the real akm CLI: its `process.argv[1]` is the
544
+ // akm entrypoint, so the background auto-reindex may safely re-invoke it as a
545
+ // detached child. Hosts that merely import this module (the in-process test
546
+ // harness, library embeddings) never reach this block, so they fall back to
547
+ // an inline reindex instead of spawning the wrong program. See
548
+ // `ensureIndex` in src/indexer/ensure-index.ts.
549
+ process.env.AKM_CLI_ENTRY = "1";
508
550
  // citty reads process.argv directly and does not accept a custom argv array,
509
551
  // so we must replace process.argv with the normalized version before runMain.
510
552
  process.argv = normalizeShowArgv(process.argv);
@@ -122,23 +122,36 @@ export const lintCommand = defineCommand({
122
122
  description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues. Exits 0 on success regardless of findings; use --fail-on-flagged for CI fail-on-finding behavior.",
123
123
  },
124
124
  args: {
125
- fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
125
+ fix: {
126
+ type: "boolean",
127
+ alias: "auto-fix",
128
+ description: "Apply auto-fixes in place (alias: --auto-fix)",
129
+ default: false,
130
+ },
126
131
  dir: { type: "string", description: "Override stash root directory (default: from config)" },
127
132
  "fail-on-flagged": {
128
133
  type: "boolean",
129
134
  description: "Exit non-zero when summary.flagged > 0 (CI-friendly). Default: exit 0 regardless of findings.",
130
135
  default: false,
131
136
  },
137
+ type: {
138
+ type: "string",
139
+ description: "Only lint assets of this type (e.g. workflows, tasks, memories)",
140
+ default: undefined,
141
+ },
132
142
  },
133
143
  async run({ args }) {
134
144
  await runWithJsonErrors(async () => {
135
145
  const result = akmLint({
136
146
  fix: args.fix ?? false,
137
147
  dir: getStringArg(args, "dir"),
148
+ typeFilter: getStringArg(args, "type"),
138
149
  });
139
150
  output("lint", result);
140
- if (args["fail-on-flagged"] && result.summary.flagged > 0)
141
- process.exit(EXIT_GENERAL);
151
+ if (args["fail-on-flagged"] && result.summary.flagged > 0) {
152
+ process.exitCode = EXIT_GENERAL;
153
+ return;
154
+ }
142
155
  });
143
156
  },
144
157
  });
@@ -11,10 +11,9 @@ import { writeFileAtomic } from "../core/common.js";
11
11
  import { FEEDBACK_FAILURE_MODES, loadConfig } from "../core/config/config.js";
12
12
  import { UsageError } from "../core/errors.js";
13
13
  import { appendEvent } from "../core/events.js";
14
+ import { getDbPath } from "../core/paths.js";
14
15
  import { warn } from "../core/warn.js";
15
16
  import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, getEntryFilePathById, openExistingDatabase, } from "../indexer/db/db.js";
16
- import { ensureIndex } from "../indexer/ensure-index.js";
17
- import { resolveSourceEntries } from "../indexer/search/search-source.js";
18
17
  import { countFeedbackSignals, insertUsageEvent } from "../indexer/usage/usage-events.js";
19
18
  // ── Tag validation ────────────────────────────────────────────────────────────
20
19
  const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
@@ -203,11 +202,21 @@ export const feedbackCommand = defineCommand({
203
202
  ...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
204
203
  };
205
204
  const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
206
- // Auto-index when stale so the index is current before recording feedback.
207
- const sources = resolveSourceEntries();
208
- if (sources.length > 0) {
209
- await ensureIndex(sources[0].path);
205
+ // Feedback only needs the index to exist, not to be current. A stale index
206
+ // is fine — the ref lookup works against any populated DB. We do NOT call
207
+ // ensureIndex here: it either blocks (3+ min inline reindex) or spawns a
208
+ // background process that holds the writer lock, causing the feedback write
209
+ // to spin-wait for the full reindex duration. If the DB is absent we give a
210
+ // clear error below rather than silently triggering a rebuild.
211
+ if (!fs.existsSync(getDbPath())) {
212
+ throw new UsageError("Index not found. Run 'akm index' first to build the index before recording feedback.", "MISSING_REQUIRED_ARGUMENT", "akm index");
210
213
  }
214
+ // Feedback writes exactly 2 rows (usage_events + utility_score). SQLite
215
+ // WAL mode + busy_timeout=30s handles concurrent access with an ongoing
216
+ // `akm improve` run without needing the application-level writer lock.
217
+ // The lock was originally needed to prevent feedback from racing a
218
+ // background reindex it spawned — now that ensureIndex is removed, holding
219
+ // the lock only causes feedback to block for the full improve run duration.
211
220
  let utilityResult;
212
221
  const db = openExistingDatabase();
213
222
  try {
@@ -12,6 +12,7 @@ import { closeDatabase, findEntryIdByRef, getEntryById, getEntryRefRowsForStashR
12
12
  import { loadStoredGraphSnapshot } from "../../indexer/db/graph-db.js";
13
13
  import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
14
14
  import { runGraphExtractionPass } from "../../indexer/graph/graph-extraction.js";
15
+ import { withIndexWriterLease } from "../../indexer/index-writer-lock.js";
15
16
  import { lookup } from "../../indexer/indexer.js";
16
17
  import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
17
18
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
@@ -375,85 +376,88 @@ export async function akmGraphUpdate(options) {
375
376
  }
376
377
  }
377
378
  const scoped = Array.isArray(options.refs) && options.refs.length > 0;
378
- let candidatePaths;
379
- if (scoped && options.refs) {
380
- // Resolve each ref to an absolute file path via the index DB.
381
- const dbPath = getDbPath();
382
- let db;
383
- const resolvedPaths = new Set();
384
- try {
385
- db = openDatabase(dbPath);
386
- for (const ref of options.refs) {
387
- const trimmed = ref.trim();
388
- if (!trimmed)
389
- continue;
390
- const entryId = findEntryIdByRef(db, trimmed);
391
- if (entryId === undefined) {
392
- warn(`[graph] ref not found in index, skipping: ${trimmed}`);
393
- continue;
379
+ return withIndexWriterLease({ purpose: "graph-update" }, async () => {
380
+ let candidatePaths;
381
+ if (scoped && options.refs) {
382
+ // Resolve each ref to an absolute file path while the writer lease is held
383
+ // so the scoped graph write sees the same index snapshot it resolved from.
384
+ const dbPath = getDbPath();
385
+ let db;
386
+ const resolvedPaths = new Set();
387
+ try {
388
+ db = openDatabase(dbPath);
389
+ for (const ref of options.refs) {
390
+ const trimmed = ref.trim();
391
+ if (!trimmed)
392
+ continue;
393
+ const entryId = findEntryIdByRef(db, trimmed);
394
+ if (entryId === undefined) {
395
+ warn(`[graph] ref not found in index, skipping: ${trimmed}`);
396
+ continue;
397
+ }
398
+ const row = getEntryById(db, entryId);
399
+ if (!row?.filePath) {
400
+ warn(`[graph] could not resolve path for ref, skipping: ${trimmed}`);
401
+ continue;
402
+ }
403
+ resolvedPaths.add(row.filePath);
394
404
  }
395
- const row = getEntryById(db, entryId);
396
- if (!row?.filePath) {
397
- warn(`[graph] could not resolve path for ref, skipping: ${trimmed}`);
398
- continue;
399
- }
400
- resolvedPaths.add(row.filePath);
401
405
  }
406
+ finally {
407
+ if (db)
408
+ closeDatabase(db);
409
+ }
410
+ if (resolvedPaths.size === 0) {
411
+ warn("[graph] none of the provided refs resolved to indexed paths — no extraction performed.");
412
+ return {
413
+ shape: "graph-update",
414
+ ok: true,
415
+ filesExtracted: 0,
416
+ entitiesUpserted: 0,
417
+ relationsUpserted: 0,
418
+ durationMs: 0,
419
+ scoped: true,
420
+ };
421
+ }
422
+ candidatePaths = resolvedPaths;
402
423
  }
403
- finally {
404
- if (db)
405
- closeDatabase(db);
406
- }
407
- if (resolvedPaths.size === 0) {
408
- warn("[graph] none of the provided refs resolved to indexed paths — no extraction performed.");
424
+ const extractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
425
+ const passOptions = candidatePaths ? { candidatePaths } : {};
426
+ let db;
427
+ const startMs = Date.now();
428
+ try {
429
+ db = openDatabase(getDbPath());
430
+ const onProgress = (event) => {
431
+ if (!event.currentPath)
432
+ return;
433
+ const file = path.basename(event.currentPath);
434
+ warn(`[graph] extracting ${event.processed}/${event.total} ${file}`);
435
+ };
436
+ const result = await extractionFn({
437
+ config,
438
+ sources,
439
+ signal: undefined,
440
+ db,
441
+ reEnrich: false,
442
+ onProgress,
443
+ options: passOptions,
444
+ });
445
+ const durationMs = Date.now() - startMs;
409
446
  return {
410
447
  shape: "graph-update",
411
448
  ok: true,
412
- filesExtracted: 0,
413
- entitiesUpserted: 0,
414
- relationsUpserted: 0,
415
- durationMs: 0,
416
- scoped: true,
449
+ filesExtracted: result.quality.extractedFiles,
450
+ entitiesUpserted: result.quality.entityCount,
451
+ relationsUpserted: result.quality.relationCount,
452
+ durationMs,
453
+ scoped,
417
454
  };
418
455
  }
419
- candidatePaths = resolvedPaths;
420
- }
421
- const extractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
422
- const passOptions = candidatePaths ? { candidatePaths } : {};
423
- let db;
424
- const startMs = Date.now();
425
- try {
426
- db = openDatabase(getDbPath());
427
- const onProgress = (event) => {
428
- if (!event.currentPath)
429
- return;
430
- const file = path.basename(event.currentPath);
431
- warn(`[graph] extracting ${event.processed}/${event.total} ${file}`);
432
- };
433
- const result = await extractionFn({
434
- config,
435
- sources,
436
- signal: undefined,
437
- db,
438
- reEnrich: false,
439
- onProgress,
440
- options: passOptions,
441
- });
442
- const durationMs = Date.now() - startMs;
443
- return {
444
- shape: "graph-update",
445
- ok: true,
446
- filesExtracted: result.quality.extractedFiles,
447
- entitiesUpserted: result.quality.entityCount,
448
- relationsUpserted: result.quality.relationCount,
449
- durationMs,
450
- scoped,
451
- };
452
- }
453
- finally {
454
- if (db)
455
- closeDatabase(db);
456
- }
456
+ finally {
457
+ if (db)
458
+ closeDatabase(db);
459
+ }
460
+ });
457
461
  }
458
462
  async function resolveGraphTarget(ref, source) {
459
463
  const parsedRef = parseAssetRef(ref);
@@ -257,6 +257,54 @@ export const HEALTH_CHECKS = [
257
257
  };
258
258
  },
259
259
  },
260
+ {
261
+ // #603: pool-saturation advisory. The raw `sessionsScanned` count fired on
262
+ // normal cadence changes (the Jun 12 false alarm). Instead track the ratio
263
+ // of NEW (unseen) sessions to the total session pool extract evaluated in
264
+ // the window: a low ratio is the *expected* steady state, only a near-zero
265
+ // ratio signals a possible discovery/dedup bug.
266
+ //
267
+ // unseen ≈ `sessionsScanned` (extract only processes new sessions; already-
268
+ // seen ones are deduped into `sessionsSkipped`). total = scanned + skipped.
269
+ // This is a heuristic approximation — `sessionsSkipped` also folds in
270
+ // too-short skips — so the check is informational and never gates status.
271
+ name: "pool-saturation",
272
+ channel: "advisory",
273
+ run: (ctx) => {
274
+ const sx = ctx.sessionExtraction;
275
+ const total = sx.sessionsScanned + sx.sessionsSkipped;
276
+ const unseen = sx.sessionsScanned;
277
+ const ratio = total > 0 ? unseen / total : null;
278
+ const pct = ratio === null ? null : Math.round(ratio * 1000) / 10;
279
+ let status = "pass";
280
+ let confidence = "low";
281
+ let message;
282
+ if (!sx.ran || ratio === null) {
283
+ message = "Pool saturation: no extract activity in the window — no signal.";
284
+ }
285
+ else if (ratio < 0.02) {
286
+ status = "warn";
287
+ confidence = "medium";
288
+ message = `Session pool near-exhausted: only ${pct}% of the ${total}-session pool was new (<2%). Possible discovery/dedup bug — verify extract is still finding new sessions.`;
289
+ }
290
+ else if (ratio < 0.1) {
291
+ confidence = "medium";
292
+ message = `Session pool saturation: ${pct}% of ${total} sessions were new (<10%, steady-state expected — informational).`;
293
+ }
294
+ else {
295
+ confidence = "medium";
296
+ message = `Session pool healthy: ${pct}% of ${total} sessions were new.`;
297
+ }
298
+ return {
299
+ name: "pool-saturation",
300
+ kind: "heuristic",
301
+ status,
302
+ confidence,
303
+ message,
304
+ evidence: { totalSessions: total, unseenSessions: unseen, saturationRatio: ratio },
305
+ };
306
+ },
307
+ },
260
308
  {
261
309
  name: "auto-accept-validation",
262
310
  channel: "advisory",