akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -6,7 +6,7 @@
6
6
  * (#582). Ports the external akm-health-report skill's collect.py + render.py
7
7
  * to TypeScript so the report is generated in-process (no python, no
8
8
  * shell-out). The template (`src/assets/templates/html/health.html`) is a
9
- * verbatim copy of the skill's report.html; this module computes the 17
9
+ * strict superset of the skill's report.html; this module computes the 17
10
10
  * `%%TOKEN%%` replacements it consumes.
11
11
  *
12
12
  * Determinism: nothing here depends on Date.now()/Math.random(). Runs are
@@ -17,13 +17,44 @@ import fs from "node:fs";
17
17
  import path from "node:path";
18
18
  import { escapeHtml } from "../../output/html-render.js";
19
19
  import { getDirname } from "../../runtime.js";
20
+ import { pkgVersion } from "../../version.js";
21
+ /**
22
+ * Distill skip-reasons hidden from the breakdown chart. `no new signal since
23
+ * last proposal` is the steady-state "nothing changed, nothing to do" outcome —
24
+ * it dominates the histogram and drowns out the actionable reasons, so it is
25
+ * intentionally excluded from the chart (the count still lives in the data).
26
+ */
27
+ const DISTILL_REASONS_HIDDEN = new Set(["no new signal since last proposal"]);
20
28
  const ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";
21
29
  const ECHARTS_VENDOR_PATH = path.join(getDirname(import.meta.url), "../../assets/templates/html/vendor/echarts.min.js");
22
30
  // ── Small formatters (ports of render.py helpers) ───────────────────────────
23
31
  const esc = escapeHtml;
32
+ /** Emit a <time> element that the browser's JS will reformat to the viewer's local timezone. */
33
+ function isoTimeTag(iso) {
34
+ const fallback = iso.slice(0, 16).replace("T", " ");
35
+ return `<time data-iso="${esc(iso)}">${esc(fallback)}</time>`;
36
+ }
24
37
  function num(value) {
25
38
  return Math.round(value).toLocaleString("en-US");
26
39
  }
40
+ /** Compact token/count formatter, e.g. 127345 → "127K", 1_500_000 → "1.5M". */
41
+ function compact(value) {
42
+ const v = Math.round(value);
43
+ if (Math.abs(v) >= 1_000_000)
44
+ return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
45
+ if (Math.abs(v) >= 1_000)
46
+ return `${(v / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
47
+ return String(v);
48
+ }
49
+ /** Humanize a camelCase / kebab / snake enum into reader-facing text. */
50
+ function humanize(raw) {
51
+ return raw
52
+ .replace(/[-_]/g, " ")
53
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
54
+ .replace(/\s+/g, " ")
55
+ .trim()
56
+ .replace(/^./, (c) => c.toUpperCase());
57
+ }
27
58
  function fmtMs(ms) {
28
59
  return ms ? `${(ms / 60000).toFixed(1)}m` : "—";
29
60
  }
@@ -60,6 +91,7 @@ function reshapeRun(r) {
60
91
  const geMs = ge.durationMs || 0;
61
92
  return {
62
93
  id: r.id,
94
+ taskId: r.taskId ?? "manual",
63
95
  startedAt: r.startedAt,
64
96
  completedAt: r.completedAt,
65
97
  wallTimeMs: wall,
@@ -144,11 +176,14 @@ function deltaPill(deltas, key, lowerIsBetter = false) {
144
176
  const signed = `${raw > 0 ? "+" : ""}${Math.round(raw)}%`;
145
177
  return `<span class="trend-pill ${cls}">${arrow} ${signed}</span>`;
146
178
  }
147
- // ── Advisory / watch-item cards ──────────────────────────────────────────────
148
- function advisoryCard(cls, icon, title, descHtml) {
149
- return (`<div class="advisory ${cls}"><div class="advisory-icon">${icon}</div>` +
150
- `<div class="advisory-body"><div class="title">${title}</div>` +
151
- `<div class="desc">${descHtml}</div></div></div>`);
179
+ const PRIO_RANK = { P1: 0, P2: 1, P3: 2 };
180
+ function actionItemCard(item) {
181
+ const icon = item.cls === "fail" ? "🔴" : item.prio === "P3" ? "🟡" : "⚠️";
182
+ const remedy = item.remedy ? `<div class="remedy">Fix: <code>${esc(item.remedy)}</code></div>` : "";
183
+ return (`<div class="advisory ${item.cls}"><div class="advisory-icon">${icon}</div>` +
184
+ `<div class="advisory-body">` +
185
+ `<div class="title"><span class="prio ${item.prio.toLowerCase()}">${item.prio}</span>${item.title}</div>` +
186
+ `<div class="desc">${item.descHtml}</div>${remedy}</div></div>`);
152
187
  }
153
188
  function passCard(title, desc) {
154
189
  return ('<div class="advisory" style="border-left:3px solid var(--green);">' +
@@ -168,6 +203,43 @@ function readSemSearch(advisories) {
168
203
  return { blocked, detail: `${num(entries)} entries, ${num(embeddings)} embeddings` };
169
204
  }
170
205
  // ── ECharts delivery ─────────────────────────────────────────────────────────
206
+ /** Parse an akm window string (`24h`, `7d`, `30m`, `2w`) to milliseconds; 0 if unparseable. */
207
+ function windowToMs(window) {
208
+ const m = /^(\d+)\s*([mhdw])$/i.exec(window.trim());
209
+ if (!m)
210
+ return 0;
211
+ const n = Number(m[1]);
212
+ const mult = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2].toLowerCase()] ?? 0;
213
+ return n * mult;
214
+ }
215
+ /**
216
+ * Build the time-slice `<option>`s for the report's filter bar, DERIVED from the
217
+ * actual report window so the choices always make sense (the old hard-coded
218
+ * 1d–21d list was useless on a 24h or 7d report). "All" is the default; the
219
+ * sub-window options carry their cutoff in milliseconds (consumed by
220
+ * filteredRuns), largest first, only those strictly shorter than the window.
221
+ */
222
+ function buildSliceOptions(window) {
223
+ const windowMs = windowToMs(window);
224
+ const HOUR = 3_600_000;
225
+ const DAY = 86_400_000;
226
+ const candidates = [
227
+ [6 * HOUR, "6h"],
228
+ [12 * HOUR, "12h"],
229
+ [DAY, "1d"],
230
+ [3 * DAY, "3d"],
231
+ [7 * DAY, "7d"],
232
+ [14 * DAY, "14d"],
233
+ ];
234
+ const subs = candidates
235
+ .filter(([ms]) => windowMs > 0 && ms < windowMs)
236
+ .sort((a, b) => b[0] - a[0])
237
+ .slice(0, 4);
238
+ const opts = [`<option value="all" selected>All (${esc(window)})</option>`];
239
+ for (const [ms, label] of subs)
240
+ opts.push(`<option value="${ms}">Last ${label}</option>`);
241
+ return opts.join("\n ");
242
+ }
171
243
  function buildEchartsTag(opts) {
172
244
  const mode = opts.echarts ?? (process.env.AKM_ECHARTS === "cdn" ? "cdn" : "inline");
173
245
  if (mode === "cdn")
@@ -182,7 +254,8 @@ function buildEchartsTag(opts) {
182
254
  * Compute all 17 `%%TOKEN%%` replacements for the health HTML template.
183
255
  * There is deliberately NO standalone `%%OVERALL_STATUS%%` token — the
184
256
  * overall status is embedded in the pre-rendered badge / exec-summary
185
- * fragments, matching the skill template.
257
+ * fragments, matching the skill template. Advisories and "what to watch" are
258
+ * merged + de-duplicated into a single prioritized `%%ACTION_ITEMS_HTML%%`.
186
259
  */
187
260
  export function buildHealthHtmlReplacements(result, opts) {
188
261
  const deltas = opts.deltas ?? {};
@@ -196,6 +269,31 @@ export function buildHealthHtmlReplacements(result, opts) {
196
269
  const mi = improve.memoryInference;
197
270
  const ge = improve.graphExtraction;
198
271
  const wallTime = improve.wallTime;
272
+ // WS-5: perf telemetry, coverage, and degradation metrics.
273
+ const perf = improve.perfTelemetry ?? {
274
+ dedupPoolSize: 0,
275
+ llmPoolSize: 0,
276
+ judgedCacheSkipped: 0,
277
+ embedMs: 0,
278
+ embedCacheHits: 0,
279
+ embedCacheMisses: 0,
280
+ overBudgetRuns: 0,
281
+ runsWithTelemetry: 0,
282
+ };
283
+ const coverage = improve.coverage;
284
+ const degradation = improve.degradation;
285
+ // #576: real per-stage LLM token/time accounting (replaces the GPU-time
286
+ // proxy). Optional-guarded so reports built from older health JSON without
287
+ // the aggregate still render.
288
+ const llm = result.metrics.llmUsage ?? {
289
+ calls: 0,
290
+ totalDurationMs: 0,
291
+ promptTokens: 0,
292
+ completionTokens: 0,
293
+ totalTokens: 0,
294
+ reasoningTokens: 0,
295
+ byStage: {},
296
+ };
199
297
  const totalRuns = runs.length;
200
298
  const failedRuns = runs.filter((r) => !r.ok).length;
201
299
  const invoked = improve.invoked || totalRuns;
@@ -212,11 +310,23 @@ export function buildHealthHtmlReplacements(result, opts) {
212
310
  // ── Meta (collect.py steps 10-11) ──────────────────────────────────────────
213
311
  const sinceIso = result.since;
214
312
  const reportDate = sinceIso.slice(0, 10);
215
- const sinceHuman = sinceIso ? `${sinceIso.slice(0, 16).replace("T", " ")} UTC → now` : `last ${opts.window}`;
313
+ const sinceHuman = sinceIso ? `${isoTimeTag(sinceIso)} → now` : `last ${esc(opts.window)}`;
216
314
  const reportTitle = reportDate ? `AKM Health Report — ${reportDate}` : "AKM Health Report";
217
315
  const lastRun = runs[runs.length - 1];
218
316
  const generatedAt = lastRun ? lastRun.completedAt || lastRun.startedAt || sinceIso : sinceIso;
219
317
  const latest = [...runs].reverse().find((r) => r.ok) ?? lastRun;
318
+ // Freshness: surface the newest run's timestamp + the generated-at anchor in
319
+ // the exec card. Staleness is computed deterministically (no Date.now()) from
320
+ // the gap between the window start (`since`) and the newest run we have: if no
321
+ // run landed in the window, or the newest run sits in the first 25% of a
322
+ // window wider than the 6h threshold (i.e. a long idle tail), we flag stale.
323
+ const STALE_MS = 6 * 60 * 60 * 1000;
324
+ const latestRunMs = lastRun ? Date.parse(lastRun.completedAt || lastRun.startedAt) : NaN;
325
+ const sinceMs = Date.parse(sinceIso);
326
+ const generatedMs = Date.parse(generatedAt);
327
+ const idleTailMs = Number.isFinite(latestRunMs) && Number.isFinite(generatedMs) ? Math.max(0, generatedMs - latestRunMs) : 0;
328
+ const isStale = totalRuns === 0 || (Number.isFinite(latestRunMs) && Number.isFinite(sinceMs) && idleTailMs > STALE_MS);
329
+ const latestRunHuman = lastRun ? isoTimeTag(lastRun.completedAt || lastRun.startedAt) : "—";
220
330
  // ── Status badges ──────────────────────────────────────────────────────────
221
331
  const badgeByStatus = {
222
332
  pass: { badge: "badge-pass", dot: "dot-pass", label: "PASS" },
@@ -238,7 +348,7 @@ export function buildHealthHtmlReplacements(result, opts) {
238
348
  li("MI yield rate", miYieldRate),
239
349
  li("MI written", num(miWritten)),
240
350
  li("Consolidation promoted", num(cons.promoted)),
241
- li("Consolidation judgedNoAction", num(cons.judgedNoAction)),
351
+ li("Consolidation judged: no action", `<abbr title="Candidates the consolidator reviewed but intentionally left unchanged (the 'judgedNoAction' field).">${num(cons.judgedNoAction)}</abbr>`),
242
352
  li("Chunk failure", chunkFail),
243
353
  li("Median wall time", fmtMs(wallTime.medianMs)),
244
354
  li("P95 wall time", fmtMs(wallTime.p95Ms)),
@@ -259,12 +369,12 @@ export function buildHealthHtmlReplacements(result, opts) {
259
369
  const snapRows = latest
260
370
  ? [
261
371
  li("Run id", `<code>${esc(latest.id.slice(0, 28))}</code>`),
262
- li("Completed", `${esc((latest.completedAt || latest.startedAt).slice(0, 16).replace("T", " "))} UTC`),
372
+ li("Completed", isoTimeTag(latest.completedAt || latest.startedAt)),
263
373
  li("Status", latest.ok ? "✅ ok" : "❌ failed"),
264
374
  li("Wall time", fmtMs(latest.wallTimeMs)),
265
375
  li("Reflect ok/fail", `${latest.reflectOk} / ${latest.reflectFailed}`),
266
376
  li("Promoted", String(latest.promoted)),
267
- li("judgedNoAction", String(latest.judgedNoAction)),
377
+ li("Judged: no action", `<abbr title="Candidates reviewed but intentionally left unchanged on this run.">${latest.judgedNoAction}</abbr>`),
268
378
  li("MI written", String(latest.miWritten)),
269
379
  li("Graph entities/relations", `${latest.geEntities} / ${latest.geRelations}`),
270
380
  ].join("")
@@ -273,16 +383,50 @@ export function buildHealthHtmlReplacements(result, opts) {
273
383
  li("Report window", esc(opts.window)),
274
384
  li("Compare window", esc(opts.compare)),
275
385
  li("Runs", `${num(totalRuns)} (${failedRuns} failed)`),
276
- li("Stash derived", num(improve.memorySummary.derived)),
277
- li("Stash eligible", num(improve.memorySummary.eligible)),
386
+ li("Stash derived", `<abbr title="Whole-stash recount of derived assets at report time — not a per-run sum.">${num(improve.memorySummary.derived)}</abbr>`),
387
+ li("Stash eligible", `<abbr title="Whole-stash recount of eligible assets at report time — not a per-run sum.">${num(improve.memorySummary.eligible)}</abbr>`),
278
388
  li("Pending proposals", String(proposals.length)),
279
389
  li("Semantic search", sem.blocked ? "BLOCKED" : "OK"),
280
390
  ].join("");
281
391
  const overallEmoji = trend.overall === "improving" ? "📈" : trend.overall === "degrading" ? "📉" : "↔️";
392
+ // ── Synthesized verdict (one sentence + 2-3 drivers) ───────────────────────
393
+ // The verdict WORD is the authoritative health status (hard checks). Concerns
394
+ // (failed improve runs, blocked search, pending proposals) are advisory and do
395
+ // NOT gate that word, so we must not pair a green PASS with red "drivers" as
396
+ // if they were failures. When the status is PASS we frame concerns as "watch:"
397
+ // alongside the trend; only a degraded status (WARN/FAIL) leads with them.
398
+ const concerns = [];
399
+ if (failedRuns > 0)
400
+ concerns.push(`${failedRuns} failed run${failedRuns === 1 ? "" : "s"}`);
401
+ if (sem.blocked)
402
+ concerns.push("semantic search blocked");
403
+ if (proposals.length > 0)
404
+ concerns.push(`${proposals.length} pending proposal${proposals.length === 1 ? "" : "s"}`);
405
+ const trendClause = trend.overall === "improving"
406
+ ? "throughput and latency improving"
407
+ : trend.overall === "degrading"
408
+ ? "throughput or latency degrading"
409
+ : "throughput and latency steady";
410
+ const verdictWord = badge.label;
411
+ let verdictRest;
412
+ if (result.status === "pass") {
413
+ const watch = concerns.length > 0 ? `; watch: ${concerns.slice(0, 3).join(", ")}` : "";
414
+ verdictRest = `healthy, ${trendClause}${watch}`;
415
+ }
416
+ else {
417
+ const lead = concerns.length > 0 ? concerns.slice(0, 3).join("; ") : trendClause;
418
+ verdictRest = trend.overall === "degrading" && concerns.length > 0 ? `${lead}; ${trendClause}` : lead;
419
+ }
420
+ const verdictSentence = `${verdictWord} — ${esc(verdictRest)}.`;
421
+ const verdictHtml = `<div class="verdict ${result.status}"><b>Verdict:</b> ${verdictSentence}</div>`;
422
+ // ── Freshness line ─────────────────────────────────────────────────────────
423
+ const freshnessHtml = `<div class="freshness${isStale ? " stale" : ""}">${isStale ? "⚠️ Stale: " : ""}Latest run ${latestRunHuman} &nbsp;·&nbsp; generated ${isoTimeTag(generatedAt)}${isStale ? " — no recent activity (newest run older than the 6h freshness threshold)." : "."}</div>`;
282
424
  const execSummary = `
283
425
  <h2>${overallEmoji} Executive Summary
284
426
  <span class="badge-pill ${badge.badge}" style="font-size:11px;">
285
427
  <span class="dot ${badge.dot}"></span>${badge.label}</span></h2>
428
+ ${verdictHtml}
429
+ ${freshnessHtml}
286
430
  <div class="exec-grid">
287
431
  <div>
288
432
  <h4>Quick Numbers</h4>
@@ -306,143 +450,341 @@ export function buildHealthHtmlReplacements(result, opts) {
306
450
  <div class="overall">Overall trend: <b>${esc(trend.overall)}</b> ${overallEmoji}
307
451
  &nbsp;·&nbsp; based on decision quality, output volume, failures, and latency vs the prior window.</div>`.trim();
308
452
  // ── KPI cards ──────────────────────────────────────────────────────────────
453
+ // Color is a health SIGNAL, not decoration: green/yellow/red where a card has
454
+ // a meaningful threshold; "neutral" for purely-informational counts. Cards are
455
+ // ordered by operator priority (failures first, informational counts last).
309
456
  const semValue = sem.blocked ? "BLOCKED" : "OK";
310
- const semColor = sem.blocked ? "yellow" : "green";
457
+ const semColor = sem.blocked ? "red" : "green";
311
458
  const semStyle = sem.blocked ? "font-size:18px;" : "";
459
+ const completionPct = invoked ? (100 * completed) / invoked : 100;
460
+ const completionColor = completionPct >= 99 ? "green" : completionPct >= 90 ? "yellow" : "red";
461
+ const llmTokensCompact = compact(llm.totalTokens);
312
462
  const kpiCard = (color, label, value, sub, valueStyle = "") => `<div class="kpi-card ${color}">
313
463
  <div class="label">${label}</div>
314
464
  <div class="value"${valueStyle ? ` style="${valueStyle}"` : ""}>${value}</div>
315
465
  <div class="sub">${sub}</div>
316
466
  </div>`;
317
467
  const kpiCards = [
318
- kpiCard(failedRuns === 0 ? "green" : "yellow", "Completion Rate", completionRate, `${num(completed)} / ${num(invoked)} invoked`),
319
468
  kpiCard(failedRuns === 0 ? "green" : "red", "Failed Runs", String(failedRuns), `of ${num(totalRuns)} runs · ${taskFailRate} task fail`),
469
+ kpiCard(completionColor, "Completion Rate", completionRate, `${num(completed)} / ${num(invoked)} invoked`),
470
+ kpiCard("neutral", "Median Duration", `${medianDurMin}m`, `p95 = ${p95DurMin}m`),
320
471
  kpiCard("blue", "Total Promoted", num(cons.promoted), `avg ${avgPromoted} / run`),
321
472
  kpiCard("blue", "MI Written", num(miWritten), `${miYieldRate} yield rate`),
322
473
  kpiCard("purple", "Graph Entities", num(ge.entities), `+${num(ge.relations)} relations`),
323
- kpiCard("green", "Stash Derived", num(improve.memorySummary.derived), `of ${num(improve.memorySummary.eligible)} eligible`),
324
- kpiCard("yellow", "Median Duration", `${medianDurMin}m`, `p95 = ${p95DurMin}m`),
474
+ kpiCard("neutral", "Stash Derived", num(improve.memorySummary.derived), `of ${num(improve.memorySummary.eligible)} eligible (whole-stash)`),
475
+ // #576: real LLM work — duration leads, tokens compact, not a GPU proxy.
476
+ kpiCard(llm.calls > 0 ? "purple" : "neutral", "🧠 LLM Work", `${llmTokensCompact} tok`, `${fmtMs(llm.totalDurationMs)} · ${num(llm.calls)} calls · ${compact(llm.reasoningTokens)} reasoning`),
325
477
  kpiCard(semColor, "Semantic Search", semValue, esc(sem.detail), semStyle),
326
- kpiCard("yellow", "Pending Proposals", String(proposals.length), `from ${esc(opts.window)} batch`),
478
+ kpiCard(proposals.length > 0 ? "yellow" : "neutral", "Pending Proposals", String(proposals.length), `from ${esc(opts.window)} batch`),
327
479
  ].join("\n");
328
480
  // ── Chart payload ──────────────────────────────────────────────────────────
329
- const distillReasons = [...new Set(runs.flatMap((r) => Object.keys(r.distillByReason)))].sort();
481
+ const distillReasons = [...new Set(runs.flatMap((r) => Object.keys(r.distillByReason)))]
482
+ .filter((reason) => !DISTILL_REASONS_HIDDEN.has(reason))
483
+ .sort();
330
484
  const runsJsConst = `const RUNS = ${JSON.stringify(runs)};`;
331
485
  // ── Summary table rows ─────────────────────────────────────────────────────
486
+ // Optional 4th element = a glossary tooltip rendered as <abbr title>.
332
487
  const summaryRows = [
333
488
  ["Task fail rate", taskFailRate, "flat"],
334
489
  ["Agent fail rate", agentFailRate, "flat"],
335
490
  ["Improve completion", `${num(completed)} / ${num(invoked)}`, "flat"],
336
- ["MI yield rate", miYieldRate, trend.decisionQuality],
337
- ["MI written", num(miWritten), trend.outputVolume],
491
+ [
492
+ "MI yield rate",
493
+ miYieldRate,
494
+ trend.decisionQuality,
495
+ "Memory-inference yield: share of considered candidates that produced a written fact.",
496
+ ],
497
+ ["MI written", num(miWritten), trend.outputVolume, "Memory-inference: facts written this window."],
338
498
  ["Consolidation promoted", num(cons.promoted), trend.outputVolume],
339
499
  ["Consolidation merged", num(cons.merged), "flat"],
340
500
  ["Consolidation deleted", num(cons.deleted), "flat"],
341
501
  ["Consolidation contradicted", num(cons.contradicted), "flat"],
342
- ["Consolidation judgedNoAction", num(cons.judgedNoAction), "flat"],
502
+ [
503
+ "Consolidation judged: no action",
504
+ num(cons.judgedNoAction),
505
+ "flat",
506
+ "Candidates reviewed but intentionally left unchanged (the 'judgedNoAction' field).",
507
+ ],
343
508
  ["Chunk failure", chunkFail, "flat"],
344
509
  ["Graph entities", num(ge.entities), "up"],
345
510
  ["Graph relations", num(ge.relations), "up"],
346
- ["Stash derived", num(improve.memorySummary.derived), "up"],
511
+ [
512
+ "Stash derived",
513
+ num(improve.memorySummary.derived),
514
+ "up",
515
+ "Whole-stash recount of derived assets at report time — not a per-run sum.",
516
+ ],
347
517
  ["Median wall time", fmtMs(wallTime.medianMs), trend.latency],
348
518
  ["P95 wall time", fmtMs(wallTime.p95Ms), trend.latency],
519
+ // #576: real LLM accounting (replaces the GPU-time proxy).
520
+ ["LLM calls", num(llm.calls), "flat"],
521
+ ["LLM total tokens", num(llm.totalTokens), "flat"],
522
+ ["LLM prompt tokens", num(llm.promptTokens), "flat"],
523
+ ["LLM completion tokens", num(llm.completionTokens), "flat"],
524
+ [
525
+ "LLM reasoning tokens",
526
+ num(llm.reasoningTokens),
527
+ "flat",
528
+ "Tokens spent on model reasoning/thinking, billed separately from prompt and completion.",
529
+ ],
530
+ ["LLM wall time", fmtMs(llm.totalDurationMs), trend.latency],
349
531
  ];
532
+ // #612 — auto-accept gate calibration. Only surface when the gate actually
533
+ // acted on proposals in the window (samples > 0); a default ungated install
534
+ // reports an empty summary and we omit the rows to keep the table parity-clean.
535
+ const calibration = improve.calibration;
536
+ if (calibration && calibration.samples > 0) {
537
+ summaryRows.push([
538
+ "Calibration samples",
539
+ num(calibration.samples),
540
+ "flat",
541
+ "Auto-accept gate decisions (auto-accepted + auto-rejected) the calibration join measured this window.",
542
+ ], [
543
+ "Calibration accept rate",
544
+ String(calibration.overallAcceptRate),
545
+ "flat",
546
+ "Realized accept rate of acted-on gate decisions (auto-accepted / total acted-on).",
547
+ ], [
548
+ "Calibration gap",
549
+ String(calibration.calibrationGap),
550
+ "flat",
551
+ "Mean predicted confidence minus realized accept rate. Positive = the gate is over-confident.",
552
+ ]);
553
+ }
554
+ // WS-5: denominator-fixed coverage rows (only when we have real data).
555
+ if (coverage && !Number.isNaN(coverage.rate)) {
556
+ summaryRows.push([
557
+ "Coverage rate",
558
+ pct(coverage.rate, 1),
559
+ "flat",
560
+ "Accepted proposals / total stash assets (denominator-fixed). Shows what fraction of the corpus has been touched.",
561
+ ], [
562
+ "Eligible fraction",
563
+ pct(coverage.eligibleFraction, 1),
564
+ "flat",
565
+ "Eligible assets / total stash assets. Fraction the improve pipeline actively considers.",
566
+ ], [
567
+ "Coverage accepted",
568
+ num(coverage.acceptedProposals),
569
+ "up",
570
+ "Total accepted proposals used for the denominator-fixed coverage rate.",
571
+ ]);
572
+ }
573
+ // WS-5: perf telemetry rows (only when at least one run reported telemetry).
574
+ if (perf.runsWithTelemetry > 0) {
575
+ const embedCacheTotal = perf.embedCacheHits + perf.embedCacheMisses;
576
+ const embedCacheHitRate = embedCacheTotal > 0 ? pct(perf.embedCacheHits / embedCacheTotal, 1) : "—";
577
+ summaryRows.push([
578
+ "Embed cache hit rate",
579
+ embedCacheHitRate,
580
+ "flat",
581
+ "Fraction of embedding lookups served from cache (>95% is healthy). Aggregated across WS-5 runs.",
582
+ ], [
583
+ "Embed wall time",
584
+ fmtMs(perf.embedMs),
585
+ "flat",
586
+ "Cumulative embedding wall-clock time across consolidation runs in the window.",
587
+ ], [
588
+ "Judged-cache skipped",
589
+ num(perf.judgedCacheSkipped),
590
+ "flat",
591
+ "Candidates skipped by the judged-cache (not sent to LLM). Higher = more efficient reuse of prior judgments.",
592
+ ], [
593
+ "Dedup pool size",
594
+ num(perf.dedupPoolSize),
595
+ "flat",
596
+ "Average memory pool size after deduplication (before judged-cache narrowing). WS-5 perf telemetry.",
597
+ ], [
598
+ "Over-budget consolidation runs",
599
+ String(perf.overBudgetRuns),
600
+ perf.overBudgetRuns > 0 ? "down" : "flat",
601
+ "Runs where consolidation alone exceeded the total run budget (estimatedBudgetFractionUsed > 1.0).",
602
+ ]);
603
+ }
604
+ // WS-5: degradation metrics rows.
605
+ if (degradation) {
606
+ summaryRows.push([
607
+ "Corpus diversity (Gini)",
608
+ num(degradation.corpusCentroidDistance),
609
+ degradation.entrenchmentFlagged ? "down" : "flat",
610
+ "Gini coefficient of retrieval_salience for top-100 ranked assets. High Gini (>0.35) = entrenchment risk.",
611
+ ], [
612
+ "Merge fidelity contradiction rate",
613
+ pct(degradation.mergeFidelityContradictionRate, 1),
614
+ "flat",
615
+ "Fraction of consolidated proposals that involved a contradiction, from consolidation result envelopes.",
616
+ ], [
617
+ "High-generation fraction",
618
+ pct(degradation.highGenerationFraction, 1),
619
+ "flat",
620
+ "Fraction of assets with consecutive_no_ops >= 2 (proxy for high-generation assets in the salience table).",
621
+ ]);
622
+ }
350
623
  const summaryRowsHtml = summaryRows
351
- .map(([label, value, t]) => ` <tr><td>${esc(label)}</td><td>${esc(value)}</td>` +
352
- `<td class="trend ${trendClass(t)}">${trendLabel(t)}</td></tr>`)
624
+ .map(([label, value, t, tip]) => {
625
+ const labelHtml = tip ? `<abbr title="${esc(tip)}">${esc(label)}</abbr>` : esc(label);
626
+ return (` <tr><td>${labelHtml}</td><td>${esc(value)}</td>` +
627
+ `<td class="trend ${trendClass(t)}">${trendLabel(t)}</td></tr>`);
628
+ })
353
629
  .join("\n");
354
- // ── Advisory cards ─────────────────────────────────────────────────────────
355
- const advisoryParts = [];
630
+ // ── Action Items (merged + de-duplicated advisories + what-to-watch) ────────
631
+ // Advisories and the old "what to watch" cards were built from the same data
632
+ // (result.advisories, sem-blocked, proposals, tail-latency, failed-runs). We
633
+ // collapse them into ONE prioritized, de-duplicated list (P1/P2/P3 + a
634
+ // remediation command per item), keyed so each concern appears exactly once.
635
+ const items = [];
636
+ const seen = new Set();
637
+ const pushItem = (item) => {
638
+ if (seen.has(item.key))
639
+ return;
640
+ seen.add(item.key);
641
+ items.push(item);
642
+ };
643
+ // Hard advisories from the health check engine (own remediation in message).
356
644
  for (const a of result.advisories) {
357
645
  if (a.status !== "warn" && a.status !== "fail")
358
646
  continue;
359
- advisoryParts.push(advisoryCard(a.status === "fail" ? "fail" : "warn", a.status === "fail" ? "🔴" : "⚠️", esc(a.name), esc(a.message)));
647
+ pushItem({
648
+ key: `advisory:${a.name}`,
649
+ prio: a.status === "fail" ? "P1" : "P2",
650
+ cls: a.status === "fail" ? "fail" : "warn",
651
+ title: esc(humanize(a.name)),
652
+ descHtml: esc(a.message),
653
+ });
654
+ }
655
+ // Failed runs in window.
656
+ if (failedRuns > 0) {
657
+ pushItem({
658
+ key: "failed-runs",
659
+ prio: "P1",
660
+ cls: "fail",
661
+ title: `${failedRuns} failed run${failedRuns === 1 ? "" : "s"} in window`,
662
+ descHtml: `Task fail rate ${esc(taskFailRate)}. Inspect failed runs (ok=false) for early-exit or harness errors.`,
663
+ remedy: `akm health --since=${opts.window} --group-by run`,
664
+ });
360
665
  }
666
+ // Semantic search blocked.
361
667
  if (sem.blocked) {
362
- advisoryParts.push(advisoryCard("warn", "⚠️", "Semantic search blocked", `Embedding provider unreachable. ${esc(sem.detail)}. Curate falls back to keyword search — relevance scoring degraded.`));
668
+ pushItem({
669
+ key: "semantic-search-blocked",
670
+ prio: "P2",
671
+ cls: "warn",
672
+ title: "Semantic search blocked",
673
+ descHtml: `Embedding provider unreachable. ${esc(sem.detail)}. Curate falls back to keyword search — relevance scoring degraded.`,
674
+ remedy: "akm config show",
675
+ });
363
676
  }
677
+ // Pending proposals to drain.
364
678
  if (proposals.length > 0) {
365
- advisoryParts.push(advisoryCard("warn", "⚠️", `${proposals.length} proposals pending (drain needed)`, "Run <code>akm proposal list</code> to review and drain."));
679
+ const bySource = new Map();
680
+ for (const p of proposals)
681
+ bySource.set(p.source, (bySource.get(p.source) ?? 0) + 1);
682
+ const srcSummary = [...bySource.entries()]
683
+ .sort(([a], [b]) => a.localeCompare(b))
684
+ .map(([source, count]) => `${count} via ${esc(source)}`)
685
+ .join(", ");
686
+ pushItem({
687
+ key: "drain-proposals",
688
+ prio: "P2",
689
+ cls: "warn",
690
+ title: `Drain ${proposals.length} pending proposal${proposals.length === 1 ? "" : "s"}`,
691
+ descHtml: `Proposals generated this batch (${srcSummary}). Review before the queue grows further.`,
692
+ remedy: "akm proposal list",
693
+ });
694
+ }
695
+ // High tail latency.
696
+ if (wallTime.p95Ms && wallTime.medianMs && wallTime.p95Ms / wallTime.medianMs > 2.5) {
697
+ pushItem({
698
+ key: "tail-latency",
699
+ prio: "P3",
700
+ cls: "warn",
701
+ title: `High tail latency: p95=${fmtMs(wallTime.p95Ms)}, median=${fmtMs(wallTime.medianMs)}`,
702
+ descHtml: "P95 is well above median. Consolidation/LLM phase dominates wall time on slow runs. " +
703
+ "Check for slow chunks or LLM rate limiting.",
704
+ });
705
+ }
706
+ // Stale freshness.
707
+ if (isStale && totalRuns > 0) {
708
+ pushItem({
709
+ key: "stale",
710
+ prio: "P3",
711
+ cls: "warn",
712
+ title: "No recent improve runs",
713
+ descHtml: `Newest run is ${esc(latestRunHuman)} — older than the 6h freshness threshold. Check the improve scheduler/cron.`,
714
+ });
366
715
  }
716
+ // WS-5: corpus entrenchment flag.
717
+ if (degradation?.entrenchmentFlagged) {
718
+ pushItem({
719
+ key: "corpus-entrenchment",
720
+ prio: "P2",
721
+ cls: "warn",
722
+ title: "Corpus entrenchment risk: retrieval_salience Gini > 0.35",
723
+ descHtml: "A small set of assets dominates retrieval — retrieval diversity is low. " +
724
+ "Review top-ranked assets for stale or over-represented content. " +
725
+ `Corpus diversity proxy: ${esc(String(degradation.corpusCentroidDistance))}.`,
726
+ remedy: "akm health --format json | jq '.improve.degradation'",
727
+ });
728
+ }
729
+ // WS-5: over-budget consolidation advisory.
730
+ if (perf.overBudgetRuns > 0) {
731
+ pushItem({
732
+ key: "over-budget-consolidation",
733
+ prio: "P2",
734
+ cls: "warn",
735
+ title: `${perf.overBudgetRuns} consolidation run${perf.overBudgetRuns === 1 ? "" : "s"} exceeded budget`,
736
+ descHtml: "Consolidation phase wall time exceeded the total run budget on these runs. " +
737
+ "Consider increasing the timeout or reducing the consolidation pool via profile config.",
738
+ remedy: "akm config show",
739
+ });
740
+ }
741
+ // Session-log notes (informational, lowest priority).
367
742
  if (result.sessionLogAdvisories.length > 0) {
368
743
  const patterns = result.sessionLogAdvisories
369
744
  .slice(0, 6)
370
745
  .map((p) => `<li>${esc(p.topic)}</li>`)
371
746
  .join("");
372
- advisoryParts.push('<div class="advisory" style="border-left:3px solid var(--accent);">' +
373
- '<div class="advisory-icon">ℹ️</div><div class="advisory-body">' +
374
- `<div class="title">${result.sessionLogAdvisories.length} session-log note(s) (informational)</div>` +
375
- `<div class="desc"><ul style="margin:4px 0 0 16px;padding:0;">${patterns}</ul></div></div></div>`);
747
+ pushItem({
748
+ key: "session-log-notes",
749
+ prio: "P3",
750
+ cls: "warn",
751
+ title: `${result.sessionLogAdvisories.length} session-log note(s) (informational)`,
752
+ descHtml: `<ul style="margin:4px 0 0 16px;padding:0;">${patterns}</ul>`,
753
+ });
376
754
  }
377
- const advisoryCardsHtml = advisoryParts.length > 0
378
- ? advisoryParts.join("\n")
379
- : passCard("No active advisories", "All checks passed for this window.");
755
+ items.sort((a, b) => PRIO_RANK[a.prio] - PRIO_RANK[b.prio]);
756
+ const actionItemsHtml = items.length > 0
757
+ ? items.map(actionItemCard).join("\n")
758
+ : passCard("No action items", "All checks passed and nothing needs attention for this window.");
380
759
  // ── Proposal rows ──────────────────────────────────────────────────────────
381
760
  const proposalRowsHtml = proposals.length > 0
382
761
  ? proposals
383
762
  .map((p, i) => {
384
763
  const tagCls = p.source === "extract" ? "tag-extract" : "tag-consolidate";
385
- const ts = p.createdAt.slice(0, 16).replace("T", " ");
386
764
  return (`<tr><td>${i + 1}</td><td><code>${esc(p.ref)}</code></td>` +
387
765
  `<td><span class="tag ${tagCls}">${esc(p.source)}</span></td>` +
388
- `<td>${esc(ts)}</td></tr>`);
766
+ `<td>${isoTimeTag(p.createdAt)}</td></tr>`);
389
767
  })
390
768
  .join("\n")
391
769
  : '<tr><td colspan="4" style="text-align:center;color:var(--muted);">No pending proposals</td></tr>';
392
- // ── What to watch ──────────────────────────────────────────────────────────
393
- const watchParts = [];
394
- for (const a of result.advisories) {
395
- if (a.status !== "warn" && a.status !== "fail")
396
- continue;
397
- const prio = a.status === "fail" ? "P1" : "P2";
398
- watchParts.push(advisoryCard(a.status === "fail" ? "fail" : "warn", a.status === "fail" ? "🔴" : "🟡", `${esc(a.name)} (${prio})`, esc(a.message)));
399
- }
400
- if (sem.blocked) {
401
- watchParts.push(advisoryCard("warn", "🟡", "Embedding server unreachable (P2)", "Curate quality and semantic ranking are degraded. Check the embedding endpoint configured in config.json."));
402
- }
403
- if (proposals.length > 0) {
404
- const bySource = new Map();
405
- for (const p of proposals)
406
- bySource.set(p.source, (bySource.get(p.source) ?? 0) + 1);
407
- const srcSummary = [...bySource.entries()]
408
- .sort(([a], [b]) => a.localeCompare(b))
409
- .map(([source, count]) => `${count} via ${esc(source)}`)
410
- .join(", ");
411
- watchParts.push(advisoryCard("warn", "🟡", `Drain ${proposals.length} pending proposals (P2)`, `Proposals generated this batch (${srcSummary}). Run <code>akm proposal list</code> before the queue grows further.`));
412
- }
413
- if (wallTime.p95Ms && wallTime.medianMs && wallTime.p95Ms / wallTime.medianMs > 2.5) {
414
- watchParts.push(advisoryCard("warn", "🟡", `High tail latency (P3): p95=${fmtMs(wallTime.p95Ms)}, median=${fmtMs(wallTime.medianMs)}`, "P95 is well above median. Consolidation/LLM phase dominates wall time on slow runs. " +
415
- "Check for slow chunks or LLM rate limiting."));
416
- }
417
- if (failedRuns > 0) {
418
- watchParts.push(advisoryCard("warn", "🟡", `${failedRuns} failed run(s) in window (P2)`, `Task fail rate ${taskFailRate}. Inspect failed runs (ok=false) for early-exit or harness errors.`));
419
- }
420
- const watchItemsHtml = watchParts.length > 0
421
- ? watchParts.join("\n")
422
- : passCard("Nothing critical to watch", "All indicators are within normal range.");
423
- // ── Commands used ──────────────────────────────────────────────────────────
424
- const commandsHtml = [
425
- ` <div><span>akm health --since=${esc(opts.window)} --group-by run --format json</span></div>`,
426
- ` <div><span>akm health --since=${esc(opts.window)} --window-compare=${esc(opts.compare)} --format json</span></div>`,
427
- " <div><span>akm proposal list</span></div>",
428
- ].join("\n");
429
770
  return {
430
771
  "%%ECHARTS_TAG%%": buildEchartsTag(opts),
431
772
  "%%REPORT_TITLE%%": esc(reportTitle),
432
773
  "%%WINDOW%%": esc(opts.window),
433
- "%%SINCE_HUMAN%%": esc(sinceHuman),
774
+ "%%SINCE_HUMAN%%": sinceHuman,
434
775
  "%%RUN_COUNT%%": num(totalRuns),
435
776
  "%%STATUS_BADGE_HTML%%": `${statusBadge}\n ${failBadge}`,
436
777
  "%%EXEC_SUMMARY_HTML%%": execSummary,
437
778
  "%%KPI_CARDS_HTML%%": kpiCards,
438
779
  "%%RUNS_JS_CONST%%": runsJsConst,
439
780
  "%%DISTILL_REASONS_JSON%%": JSON.stringify(distillReasons),
781
+ "%%SLICE_OPTIONS_HTML%%": buildSliceOptions(opts.window),
782
+ "%%LLM_BY_STAGE_JSON%%": JSON.stringify(llm.byStage ?? {}),
440
783
  "%%SUMMARY_ROWS_HTML%%": summaryRowsHtml,
441
- "%%ADVISORY_CARDS_HTML%%": advisoryCardsHtml,
784
+ "%%ACTION_ITEMS_HTML%%": actionItemsHtml,
442
785
  "%%PROPOSAL_ROWS_HTML%%": proposalRowsHtml,
443
786
  "%%PROPOSAL_COUNT%%": String(proposals.length),
444
- "%%WATCH_ITEMS_HTML%%": watchItemsHtml,
445
- "%%COMMANDS_HTML%%": commandsHtml,
446
787
  "%%GENERATED_AT%%": esc(generatedAt),
788
+ "%%AKM_VERSION%%": esc(pkgVersion),
447
789
  };
448
790
  }