akm-cli 0.9.0-beta.1 → 0.9.0-beta.11
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.
- package/CHANGELOG.md +492 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +201 -14
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +73 -8
- package/dist/commands/improve/improve-auto-accept.js +63 -3
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +874 -447
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +33 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +154 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `akm health --format html` — token builder for the full HTML health report
|
|
6
|
+
* (#582). Ports the external akm-health-report skill's collect.py + render.py
|
|
7
|
+
* to TypeScript so the report is generated in-process (no python, no
|
|
8
|
+
* shell-out). The template (`src/assets/templates/html/health.html`) is a
|
|
9
|
+
* strict superset of the skill's report.html; this module computes the 17
|
|
10
|
+
* `%%TOKEN%%` replacements it consumes.
|
|
11
|
+
*
|
|
12
|
+
* Determinism: nothing here depends on Date.now()/Math.random(). Runs are
|
|
13
|
+
* sorted by startedAt; `%%GENERATED_AT%%` derives from the latest run (or the
|
|
14
|
+
* window anchor), so output is byte-identical for identical inputs.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { escapeHtml } from "../../output/html-render.js";
|
|
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"]);
|
|
28
|
+
const ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";
|
|
29
|
+
const ECHARTS_VENDOR_PATH = path.join(getDirname(import.meta.url), "../../assets/templates/html/vendor/echarts.min.js");
|
|
30
|
+
// ── Small formatters (ports of render.py helpers) ───────────────────────────
|
|
31
|
+
const esc = escapeHtml;
|
|
32
|
+
function num(value) {
|
|
33
|
+
return Math.round(value).toLocaleString("en-US");
|
|
34
|
+
}
|
|
35
|
+
/** Compact token/count formatter, e.g. 127345 → "127K", 1_500_000 → "1.5M". */
|
|
36
|
+
function compact(value) {
|
|
37
|
+
const v = Math.round(value);
|
|
38
|
+
if (Math.abs(v) >= 1_000_000)
|
|
39
|
+
return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
40
|
+
if (Math.abs(v) >= 1_000)
|
|
41
|
+
return `${(v / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
|
|
42
|
+
return String(v);
|
|
43
|
+
}
|
|
44
|
+
/** Humanize a camelCase / kebab / snake enum into reader-facing text. */
|
|
45
|
+
function humanize(raw) {
|
|
46
|
+
return raw
|
|
47
|
+
.replace(/[-_]/g, " ")
|
|
48
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
52
|
+
}
|
|
53
|
+
function fmtMs(ms) {
|
|
54
|
+
return ms ? `${(ms / 60000).toFixed(1)}m` : "—";
|
|
55
|
+
}
|
|
56
|
+
function pct(rate, digits) {
|
|
57
|
+
return `${(rate * 100).toFixed(digits)}%`;
|
|
58
|
+
}
|
|
59
|
+
function trendClass(direction) {
|
|
60
|
+
return direction === "up" ? "trend-up" : direction === "down" ? "trend-down" : "trend-flat";
|
|
61
|
+
}
|
|
62
|
+
function trendLabel(direction) {
|
|
63
|
+
return direction === "up" ? "▲ up" : direction === "down" ? "▼ watch" : "— flat";
|
|
64
|
+
}
|
|
65
|
+
/** pctChange may be a number or an "+inf"/"-inf" sentinel (prior window was 0). */
|
|
66
|
+
function coercePct(raw) {
|
|
67
|
+
if (typeof raw === "number")
|
|
68
|
+
return raw;
|
|
69
|
+
if (typeof raw !== "string")
|
|
70
|
+
return undefined;
|
|
71
|
+
const s = raw.trim().toLowerCase();
|
|
72
|
+
if (s === "+inf" || s === "inf" || s === "infinity" || s === "+infinity")
|
|
73
|
+
return 1e9;
|
|
74
|
+
if (s === "-inf" || s === "-infinity")
|
|
75
|
+
return -1e9;
|
|
76
|
+
const parsed = Number.parseFloat(s.replace(/%$/, ""));
|
|
77
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
78
|
+
}
|
|
79
|
+
function reshapeRun(r) {
|
|
80
|
+
const cons = r.consolidation;
|
|
81
|
+
const mi = r.memoryInference;
|
|
82
|
+
const ge = r.graphExtraction;
|
|
83
|
+
const wall = r.wallTimeMs || 0;
|
|
84
|
+
const consMs = cons.durationMs || 0;
|
|
85
|
+
const miMs = mi.durationMs || 0;
|
|
86
|
+
const geMs = ge.durationMs || 0;
|
|
87
|
+
return {
|
|
88
|
+
id: r.id,
|
|
89
|
+
taskId: r.taskId ?? "manual",
|
|
90
|
+
startedAt: r.startedAt,
|
|
91
|
+
completedAt: r.completedAt,
|
|
92
|
+
wallTimeMs: wall,
|
|
93
|
+
ok: r.ok,
|
|
94
|
+
mode: r.scope.mode,
|
|
95
|
+
consDurationMs: consMs,
|
|
96
|
+
miDurationMs: miMs,
|
|
97
|
+
geDurationMs: geMs,
|
|
98
|
+
otherMs: Math.max(0, wall - consMs - miMs - geMs),
|
|
99
|
+
consRan: cons.ran,
|
|
100
|
+
promoted: cons.promoted,
|
|
101
|
+
merged: cons.merged,
|
|
102
|
+
deleted: cons.deleted,
|
|
103
|
+
contradicted: cons.contradicted,
|
|
104
|
+
judgedNoAction: cons.judgedNoAction,
|
|
105
|
+
processed: cons.processed,
|
|
106
|
+
failedChunks: cons.failedChunks,
|
|
107
|
+
totalChunks: cons.totalChunks,
|
|
108
|
+
miWritten: mi.written || mi.writes || 0,
|
|
109
|
+
miConsidered: mi.considered,
|
|
110
|
+
miYieldRate: mi.yieldRate,
|
|
111
|
+
miCacheHits: mi.cacheHits,
|
|
112
|
+
geEntities: ge.entities,
|
|
113
|
+
geRelations: ge.relations,
|
|
114
|
+
geCacheHitRate: ge.cacheHitRate,
|
|
115
|
+
geFailures: ge.failures,
|
|
116
|
+
distillSkipped: r.actions.distill.skipped,
|
|
117
|
+
distillQueued: r.actions.distill.queued,
|
|
118
|
+
distillLlmFailed: r.actions.distill.llmFailed,
|
|
119
|
+
distillByReason: r.actions.distill.skippedByReason,
|
|
120
|
+
reflectOk: r.actions.reflect.ok,
|
|
121
|
+
reflectFailed: r.actions.reflect.failed,
|
|
122
|
+
derived: r.memorySummary.derived,
|
|
123
|
+
eligible: r.memorySummary.eligible,
|
|
124
|
+
lintFlagged: r.lintFlagged,
|
|
125
|
+
lintFixed: r.lintFixed,
|
|
126
|
+
reflectsWithErrorContext: r.reflectsWithErrorContext,
|
|
127
|
+
orphansPurged: r.orphansPurged,
|
|
128
|
+
evalCasesWritten: r.evalCasesWritten,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// ── Trend classification (port of collect.py classify) ──────────────────────
|
|
132
|
+
function classify(deltas, metricKeys, lowerIsBetter = false) {
|
|
133
|
+
const changes = metricKeys
|
|
134
|
+
.map((key) => coercePct(deltas[key]?.pctChange))
|
|
135
|
+
.filter((c) => c !== undefined);
|
|
136
|
+
if (changes.length === 0)
|
|
137
|
+
return "flat";
|
|
138
|
+
const avg = changes.reduce((acc, c) => acc + c, 0) / changes.length;
|
|
139
|
+
if (Math.abs(avg) < 5)
|
|
140
|
+
return "flat";
|
|
141
|
+
const direction = avg > 0 ? "up" : "down";
|
|
142
|
+
if (lowerIsBetter)
|
|
143
|
+
return avg > 0 ? "down" : "up";
|
|
144
|
+
return direction;
|
|
145
|
+
}
|
|
146
|
+
function buildTrend(deltas) {
|
|
147
|
+
const decisionQuality = classify(deltas, ["improve.memoryInference.yieldRate", "improve.consolidation.promoted"]);
|
|
148
|
+
const outputVolume = classify(deltas, [
|
|
149
|
+
"improve.consolidation.promoted",
|
|
150
|
+
"improve.memoryInference.written",
|
|
151
|
+
"improve.sessionExtraction.proposalsCreated",
|
|
152
|
+
]);
|
|
153
|
+
const failures = classify(deltas, ["improve.graphExtraction.failures"], true);
|
|
154
|
+
const latency = classify(deltas, ["improve.wallTime.medianMs", "improve.wallTime.p95Ms"], true);
|
|
155
|
+
const score = [decisionQuality, outputVolume, failures, latency].reduce((acc, d) => acc + (d === "up" ? 1 : d === "down" ? -1 : 0), 0);
|
|
156
|
+
const overall = score >= 1 ? "improving" : score <= -1 ? "degrading" : "mixed";
|
|
157
|
+
return { decisionQuality, outputVolume, failures, latency, overall };
|
|
158
|
+
}
|
|
159
|
+
function deltaPill(deltas, key, lowerIsBetter = false) {
|
|
160
|
+
const raw = deltas[key]?.pctChange;
|
|
161
|
+
if (raw === undefined)
|
|
162
|
+
return '<span class="trend-pill flat">— n/a</span>';
|
|
163
|
+
if (typeof raw === "string") {
|
|
164
|
+
const sign = raw.trim().startsWith("-") ? -1 : 1;
|
|
165
|
+
const good = lowerIsBetter ? sign < 0 : sign > 0;
|
|
166
|
+
return `<span class="trend-pill ${good ? "up" : "down"}">${sign > 0 ? "▲ new" : "▼ gone"}</span>`;
|
|
167
|
+
}
|
|
168
|
+
const good = lowerIsBetter ? raw < 0 : raw > 0;
|
|
169
|
+
const cls = Math.abs(raw) < 1 ? "flat" : good ? "up" : "down";
|
|
170
|
+
const arrow = raw > 0 ? "▲" : raw < 0 ? "▼" : "—";
|
|
171
|
+
const signed = `${raw > 0 ? "+" : ""}${Math.round(raw)}%`;
|
|
172
|
+
return `<span class="trend-pill ${cls}">${arrow} ${signed}</span>`;
|
|
173
|
+
}
|
|
174
|
+
const PRIO_RANK = { P1: 0, P2: 1, P3: 2 };
|
|
175
|
+
function actionItemCard(item) {
|
|
176
|
+
const icon = item.cls === "fail" ? "🔴" : item.prio === "P3" ? "🟡" : "⚠️";
|
|
177
|
+
const remedy = item.remedy ? `<div class="remedy">Fix: <code>${esc(item.remedy)}</code></div>` : "";
|
|
178
|
+
return (`<div class="advisory ${item.cls}"><div class="advisory-icon">${icon}</div>` +
|
|
179
|
+
`<div class="advisory-body">` +
|
|
180
|
+
`<div class="title"><span class="prio ${item.prio.toLowerCase()}">${item.prio}</span>${item.title}</div>` +
|
|
181
|
+
`<div class="desc">${item.descHtml}</div>${remedy}</div></div>`);
|
|
182
|
+
}
|
|
183
|
+
function passCard(title, desc) {
|
|
184
|
+
return ('<div class="advisory" style="border-left:3px solid var(--green);">' +
|
|
185
|
+
'<div class="advisory-icon">✅</div><div class="advisory-body">' +
|
|
186
|
+
`<div class="title">${esc(title)}</div>` +
|
|
187
|
+
`<div class="desc">${esc(desc)}</div></div></div>`);
|
|
188
|
+
}
|
|
189
|
+
function readSemSearch(advisories) {
|
|
190
|
+
const check = advisories.find((a) => a.name === "semantic-search-runtime");
|
|
191
|
+
if (!check)
|
|
192
|
+
return { blocked: false, detail: "" };
|
|
193
|
+
const evidence = check.evidence ?? {};
|
|
194
|
+
const status = String(evidence.status ?? "unknown");
|
|
195
|
+
const blocked = check.status !== "pass" || status.toLowerCase().includes("block");
|
|
196
|
+
const entries = typeof evidence.entryCount === "number" ? evidence.entryCount : 0;
|
|
197
|
+
const embeddings = typeof evidence.embeddingCount === "number" ? evidence.embeddingCount : 0;
|
|
198
|
+
return { blocked, detail: `${num(entries)} entries, ${num(embeddings)} embeddings` };
|
|
199
|
+
}
|
|
200
|
+
// ── ECharts delivery ─────────────────────────────────────────────────────────
|
|
201
|
+
/** Parse an akm window string (`24h`, `7d`, `30m`, `2w`) to milliseconds; 0 if unparseable. */
|
|
202
|
+
function windowToMs(window) {
|
|
203
|
+
const m = /^(\d+)\s*([mhdw])$/i.exec(window.trim());
|
|
204
|
+
if (!m)
|
|
205
|
+
return 0;
|
|
206
|
+
const n = Number(m[1]);
|
|
207
|
+
const mult = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2].toLowerCase()] ?? 0;
|
|
208
|
+
return n * mult;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Build the time-slice `<option>`s for the report's filter bar, DERIVED from the
|
|
212
|
+
* actual report window so the choices always make sense (the old hard-coded
|
|
213
|
+
* 1d–21d list was useless on a 24h or 7d report). "All" is the default; the
|
|
214
|
+
* sub-window options carry their cutoff in milliseconds (consumed by
|
|
215
|
+
* filteredRuns), largest first, only those strictly shorter than the window.
|
|
216
|
+
*/
|
|
217
|
+
function buildSliceOptions(window) {
|
|
218
|
+
const windowMs = windowToMs(window);
|
|
219
|
+
const HOUR = 3_600_000;
|
|
220
|
+
const DAY = 86_400_000;
|
|
221
|
+
const candidates = [
|
|
222
|
+
[6 * HOUR, "6h"],
|
|
223
|
+
[12 * HOUR, "12h"],
|
|
224
|
+
[DAY, "1d"],
|
|
225
|
+
[3 * DAY, "3d"],
|
|
226
|
+
[7 * DAY, "7d"],
|
|
227
|
+
[14 * DAY, "14d"],
|
|
228
|
+
];
|
|
229
|
+
const subs = candidates
|
|
230
|
+
.filter(([ms]) => windowMs > 0 && ms < windowMs)
|
|
231
|
+
.sort((a, b) => b[0] - a[0])
|
|
232
|
+
.slice(0, 4);
|
|
233
|
+
const opts = [`<option value="all" selected>All (${esc(window)})</option>`];
|
|
234
|
+
for (const [ms, label] of subs)
|
|
235
|
+
opts.push(`<option value="${ms}">Last ${label}</option>`);
|
|
236
|
+
return opts.join("\n ");
|
|
237
|
+
}
|
|
238
|
+
function buildEchartsTag(opts) {
|
|
239
|
+
const mode = opts.echarts ?? (process.env.AKM_ECHARTS === "cdn" ? "cdn" : "inline");
|
|
240
|
+
if (mode === "cdn")
|
|
241
|
+
return `<script src="${ECHARTS_CDN}"></script>`;
|
|
242
|
+
const libPath = opts.echartsLibPath ?? ECHARTS_VENDOR_PATH;
|
|
243
|
+
// Guard against an accidental </script> in the minified payload.
|
|
244
|
+
const lib = fs.readFileSync(libPath, "utf8").replaceAll("</script>", "<\\/script>");
|
|
245
|
+
return `<script>\n${lib}\n</script>`;
|
|
246
|
+
}
|
|
247
|
+
// ── Main builder ─────────────────────────────────────────────────────────────
|
|
248
|
+
/**
|
|
249
|
+
* Compute all 17 `%%TOKEN%%` replacements for the health HTML template.
|
|
250
|
+
* There is deliberately NO standalone `%%OVERALL_STATUS%%` token — the
|
|
251
|
+
* overall status is embedded in the pre-rendered badge / exec-summary
|
|
252
|
+
* fragments, matching the skill template. Advisories and "what to watch" are
|
|
253
|
+
* merged + de-duplicated into a single prioritized `%%ACTION_ITEMS_HTML%%`.
|
|
254
|
+
*/
|
|
255
|
+
export function buildHealthHtmlReplacements(result, opts) {
|
|
256
|
+
const deltas = opts.deltas ?? {};
|
|
257
|
+
const runs = (result.runs ?? []).map(reshapeRun).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
258
|
+
const improve = result.improve;
|
|
259
|
+
const proposals = opts.proposals;
|
|
260
|
+
const sem = readSemSearch(result.advisories);
|
|
261
|
+
const trend = buildTrend(deltas);
|
|
262
|
+
// ── Aggregates (collect.py step 6) ─────────────────────────────────────────
|
|
263
|
+
const cons = improve.consolidation;
|
|
264
|
+
const mi = improve.memoryInference;
|
|
265
|
+
const ge = improve.graphExtraction;
|
|
266
|
+
const wallTime = improve.wallTime;
|
|
267
|
+
// #576: real per-stage LLM token/time accounting (replaces the GPU-time
|
|
268
|
+
// proxy). Optional-guarded so reports built from older health JSON without
|
|
269
|
+
// the aggregate still render.
|
|
270
|
+
const llm = result.metrics.llmUsage ?? {
|
|
271
|
+
calls: 0,
|
|
272
|
+
totalDurationMs: 0,
|
|
273
|
+
promptTokens: 0,
|
|
274
|
+
completionTokens: 0,
|
|
275
|
+
totalTokens: 0,
|
|
276
|
+
reasoningTokens: 0,
|
|
277
|
+
byStage: {},
|
|
278
|
+
};
|
|
279
|
+
const totalRuns = runs.length;
|
|
280
|
+
const failedRuns = runs.filter((r) => !r.ok).length;
|
|
281
|
+
const invoked = improve.invoked || totalRuns;
|
|
282
|
+
const completed = improve.completed || totalRuns - failedRuns;
|
|
283
|
+
const miWritten = mi.written || mi.writes || 0;
|
|
284
|
+
const completionRate = invoked ? `${Math.round((100 * completed) / invoked)}%` : "0%";
|
|
285
|
+
const taskFailRate = pct(result.metrics.taskFailRate, 1);
|
|
286
|
+
const agentFailRate = pct(result.metrics.agentFailureRate, 2);
|
|
287
|
+
const miYieldRate = pct(mi.yieldRate, 1);
|
|
288
|
+
const medianDurMin = (wallTime.medianMs / 60000).toFixed(1);
|
|
289
|
+
const p95DurMin = (wallTime.p95Ms / 60000).toFixed(1);
|
|
290
|
+
const avgPromoted = totalRuns ? String(Math.round(cons.promoted / totalRuns)) : "0";
|
|
291
|
+
const chunkFail = `${num(cons.failedChunks)} / ${num(cons.totalChunks)}`;
|
|
292
|
+
// ── Meta (collect.py steps 10-11) ──────────────────────────────────────────
|
|
293
|
+
const sinceIso = result.since;
|
|
294
|
+
const reportDate = sinceIso.slice(0, 10);
|
|
295
|
+
const sinceHuman = sinceIso ? `${sinceIso.slice(0, 16).replace("T", " ")} UTC → now` : `last ${opts.window}`;
|
|
296
|
+
const reportTitle = reportDate ? `AKM Health Report — ${reportDate}` : "AKM Health Report";
|
|
297
|
+
const lastRun = runs[runs.length - 1];
|
|
298
|
+
const generatedAt = lastRun ? lastRun.completedAt || lastRun.startedAt || sinceIso : sinceIso;
|
|
299
|
+
const latest = [...runs].reverse().find((r) => r.ok) ?? lastRun;
|
|
300
|
+
// Freshness: surface the newest run's timestamp + the generated-at anchor in
|
|
301
|
+
// the exec card. Staleness is computed deterministically (no Date.now()) from
|
|
302
|
+
// the gap between the window start (`since`) and the newest run we have: if no
|
|
303
|
+
// run landed in the window, or the newest run sits in the first 25% of a
|
|
304
|
+
// window wider than the 6h threshold (i.e. a long idle tail), we flag stale.
|
|
305
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
306
|
+
const latestRunMs = lastRun ? Date.parse(lastRun.completedAt || lastRun.startedAt) : NaN;
|
|
307
|
+
const sinceMs = Date.parse(sinceIso);
|
|
308
|
+
const generatedMs = Date.parse(generatedAt);
|
|
309
|
+
const idleTailMs = Number.isFinite(latestRunMs) && Number.isFinite(generatedMs) ? Math.max(0, generatedMs - latestRunMs) : 0;
|
|
310
|
+
const isStale = totalRuns === 0 || (Number.isFinite(latestRunMs) && Number.isFinite(sinceMs) && idleTailMs > STALE_MS);
|
|
311
|
+
const latestRunHuman = lastRun
|
|
312
|
+
? `${(lastRun.completedAt || lastRun.startedAt).slice(0, 16).replace("T", " ")} UTC`
|
|
313
|
+
: "—";
|
|
314
|
+
// ── Status badges ──────────────────────────────────────────────────────────
|
|
315
|
+
const badgeByStatus = {
|
|
316
|
+
pass: { badge: "badge-pass", dot: "dot-pass", label: "PASS" },
|
|
317
|
+
warn: { badge: "badge-warn", dot: "dot-warn", label: "WARN" },
|
|
318
|
+
fail: { badge: "badge-fail", dot: "dot-fail", label: "FAIL" },
|
|
319
|
+
};
|
|
320
|
+
const badge = badgeByStatus[result.status];
|
|
321
|
+
const statusBadge = `<span class="badge-pill ${badge.badge}"><span class="dot ${badge.dot}"></span>${badge.label}</span>`;
|
|
322
|
+
const failOk = result.metrics.taskFailRate < 0.05;
|
|
323
|
+
const failBadge = `<span class="badge-pill ${failOk ? "badge-pass" : "badge-warn"}">` +
|
|
324
|
+
`<span class="dot ${failOk ? "dot-pass" : "dot-warn"}"></span>${taskFailRate} Fail Rate</span>`;
|
|
325
|
+
// ── Executive summary ──────────────────────────────────────────────────────
|
|
326
|
+
const li = (k, vHtml) => `<li><span class="k">${esc(k)}</span><span class="v">${vHtml}</span></li>`;
|
|
327
|
+
const trendLi = (k, d) => li(k, `<span class="trend-pill ${d === "flat" ? "flat" : d}">${d}</span>`);
|
|
328
|
+
const quickNumbers = [
|
|
329
|
+
li("Task fail rate", taskFailRate),
|
|
330
|
+
li("Agent fail rate", agentFailRate),
|
|
331
|
+
li("Improve completion", `${num(completed)} / ${num(invoked)} (${completionRate})`),
|
|
332
|
+
li("MI yield rate", miYieldRate),
|
|
333
|
+
li("MI written", num(miWritten)),
|
|
334
|
+
li("Consolidation promoted", num(cons.promoted)),
|
|
335
|
+
li("Consolidation judged: no action", `<abbr title="Candidates the consolidator reviewed but intentionally left unchanged (the 'judgedNoAction' field).">${num(cons.judgedNoAction)}</abbr>`),
|
|
336
|
+
li("Chunk failure", chunkFail),
|
|
337
|
+
li("Median wall time", fmtMs(wallTime.medianMs)),
|
|
338
|
+
li("P95 wall time", fmtMs(wallTime.p95Ms)),
|
|
339
|
+
].join("");
|
|
340
|
+
const trendRows = [
|
|
341
|
+
trendLi("Decision quality", trend.decisionQuality),
|
|
342
|
+
trendLi("Output volume", trend.outputVolume),
|
|
343
|
+
trendLi("Failures", trend.failures),
|
|
344
|
+
trendLi("Latency", trend.latency),
|
|
345
|
+
].join("");
|
|
346
|
+
const deltaRows = [
|
|
347
|
+
li("Promoted", deltaPill(deltas, "improve.consolidation.promoted")),
|
|
348
|
+
li("MI written", deltaPill(deltas, "improve.memoryInference.written")),
|
|
349
|
+
li("MI yield", deltaPill(deltas, "improve.memoryInference.yieldRate")),
|
|
350
|
+
li("Median wall", deltaPill(deltas, "improve.wallTime.medianMs", true)),
|
|
351
|
+
li("P95 wall", deltaPill(deltas, "improve.wallTime.p95Ms", true)),
|
|
352
|
+
].join("");
|
|
353
|
+
const snapRows = latest
|
|
354
|
+
? [
|
|
355
|
+
li("Run id", `<code>${esc(latest.id.slice(0, 28))}</code>`),
|
|
356
|
+
li("Completed", `${esc((latest.completedAt || latest.startedAt).slice(0, 16).replace("T", " "))} UTC`),
|
|
357
|
+
li("Status", latest.ok ? "✅ ok" : "❌ failed"),
|
|
358
|
+
li("Wall time", fmtMs(latest.wallTimeMs)),
|
|
359
|
+
li("Reflect ok/fail", `${latest.reflectOk} / ${latest.reflectFailed}`),
|
|
360
|
+
li("Promoted", String(latest.promoted)),
|
|
361
|
+
li("Judged: no action", `<abbr title="Candidates reviewed but intentionally left unchanged on this run.">${latest.judgedNoAction}</abbr>`),
|
|
362
|
+
li("MI written", String(latest.miWritten)),
|
|
363
|
+
li("Graph entities/relations", `${latest.geEntities} / ${latest.geRelations}`),
|
|
364
|
+
].join("")
|
|
365
|
+
: '<li><span class="k">No runs in window</span><span class="v">—</span></li>';
|
|
366
|
+
const windowRows = [
|
|
367
|
+
li("Report window", esc(opts.window)),
|
|
368
|
+
li("Compare window", esc(opts.compare)),
|
|
369
|
+
li("Runs", `${num(totalRuns)} (${failedRuns} failed)`),
|
|
370
|
+
li("Stash derived", `<abbr title="Whole-stash recount of derived assets at report time — not a per-run sum.">${num(improve.memorySummary.derived)}</abbr>`),
|
|
371
|
+
li("Stash eligible", `<abbr title="Whole-stash recount of eligible assets at report time — not a per-run sum.">${num(improve.memorySummary.eligible)}</abbr>`),
|
|
372
|
+
li("Pending proposals", String(proposals.length)),
|
|
373
|
+
li("Semantic search", sem.blocked ? "BLOCKED" : "OK"),
|
|
374
|
+
].join("");
|
|
375
|
+
const overallEmoji = trend.overall === "improving" ? "📈" : trend.overall === "degrading" ? "📉" : "↔️";
|
|
376
|
+
// ── Synthesized verdict (one sentence + 2-3 drivers) ───────────────────────
|
|
377
|
+
// The verdict WORD is the authoritative health status (hard checks). Concerns
|
|
378
|
+
// (failed improve runs, blocked search, pending proposals) are advisory and do
|
|
379
|
+
// NOT gate that word, so we must not pair a green PASS with red "drivers" as
|
|
380
|
+
// if they were failures. When the status is PASS we frame concerns as "watch:"
|
|
381
|
+
// alongside the trend; only a degraded status (WARN/FAIL) leads with them.
|
|
382
|
+
const concerns = [];
|
|
383
|
+
if (failedRuns > 0)
|
|
384
|
+
concerns.push(`${failedRuns} failed run${failedRuns === 1 ? "" : "s"}`);
|
|
385
|
+
if (sem.blocked)
|
|
386
|
+
concerns.push("semantic search blocked");
|
|
387
|
+
if (proposals.length > 0)
|
|
388
|
+
concerns.push(`${proposals.length} pending proposal${proposals.length === 1 ? "" : "s"}`);
|
|
389
|
+
const trendClause = trend.overall === "improving"
|
|
390
|
+
? "throughput and latency improving"
|
|
391
|
+
: trend.overall === "degrading"
|
|
392
|
+
? "throughput or latency degrading"
|
|
393
|
+
: "throughput and latency steady";
|
|
394
|
+
const verdictWord = badge.label;
|
|
395
|
+
let verdictRest;
|
|
396
|
+
if (result.status === "pass") {
|
|
397
|
+
const watch = concerns.length > 0 ? `; watch: ${concerns.slice(0, 3).join(", ")}` : "";
|
|
398
|
+
verdictRest = `healthy, ${trendClause}${watch}`;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const lead = concerns.length > 0 ? concerns.slice(0, 3).join("; ") : trendClause;
|
|
402
|
+
verdictRest = trend.overall === "degrading" && concerns.length > 0 ? `${lead}; ${trendClause}` : lead;
|
|
403
|
+
}
|
|
404
|
+
const verdictSentence = `${verdictWord} — ${esc(verdictRest)}.`;
|
|
405
|
+
const verdictHtml = `<div class="verdict ${result.status}"><b>Verdict:</b> ${verdictSentence}</div>`;
|
|
406
|
+
// ── Freshness line ─────────────────────────────────────────────────────────
|
|
407
|
+
const freshnessHtml = `<div class="freshness${isStale ? " stale" : ""}">${isStale ? "⚠️ Stale: " : ""}Latest run ${esc(latestRunHuman)} · generated ${esc(generatedAt)}${isStale ? " — no recent activity (newest run older than the 6h freshness threshold)." : "."}</div>`;
|
|
408
|
+
const execSummary = `
|
|
409
|
+
<h2>${overallEmoji} Executive Summary
|
|
410
|
+
<span class="badge-pill ${badge.badge}" style="font-size:11px;">
|
|
411
|
+
<span class="dot ${badge.dot}"></span>${badge.label}</span></h2>
|
|
412
|
+
${verdictHtml}
|
|
413
|
+
${freshnessHtml}
|
|
414
|
+
<div class="exec-grid">
|
|
415
|
+
<div>
|
|
416
|
+
<h4>Quick Numbers</h4>
|
|
417
|
+
<ul>${quickNumbers}</ul>
|
|
418
|
+
</div>
|
|
419
|
+
<div>
|
|
420
|
+
<h4>Trend vs prior ${esc(opts.compare)}</h4>
|
|
421
|
+
<ul>${trendRows}</ul>
|
|
422
|
+
<h4 style="margin-top:14px;">Period-over-period deltas</h4>
|
|
423
|
+
<ul>${deltaRows}</ul>
|
|
424
|
+
</div>
|
|
425
|
+
<div>
|
|
426
|
+
<h4>Current Run Snapshot</h4>
|
|
427
|
+
<ul>${snapRows}</ul>
|
|
428
|
+
</div>
|
|
429
|
+
<div>
|
|
430
|
+
<h4>Window</h4>
|
|
431
|
+
<ul>${windowRows}</ul>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="overall">Overall trend: <b>${esc(trend.overall)}</b> ${overallEmoji}
|
|
435
|
+
· based on decision quality, output volume, failures, and latency vs the prior window.</div>`.trim();
|
|
436
|
+
// ── KPI cards ──────────────────────────────────────────────────────────────
|
|
437
|
+
// Color is a health SIGNAL, not decoration: green/yellow/red where a card has
|
|
438
|
+
// a meaningful threshold; "neutral" for purely-informational counts. Cards are
|
|
439
|
+
// ordered by operator priority (failures first, informational counts last).
|
|
440
|
+
const semValue = sem.blocked ? "BLOCKED" : "OK";
|
|
441
|
+
const semColor = sem.blocked ? "red" : "green";
|
|
442
|
+
const semStyle = sem.blocked ? "font-size:18px;" : "";
|
|
443
|
+
const completionPct = invoked ? (100 * completed) / invoked : 100;
|
|
444
|
+
const completionColor = completionPct >= 99 ? "green" : completionPct >= 90 ? "yellow" : "red";
|
|
445
|
+
const llmTokensCompact = compact(llm.totalTokens);
|
|
446
|
+
const kpiCard = (color, label, value, sub, valueStyle = "") => `<div class="kpi-card ${color}">
|
|
447
|
+
<div class="label">${label}</div>
|
|
448
|
+
<div class="value"${valueStyle ? ` style="${valueStyle}"` : ""}>${value}</div>
|
|
449
|
+
<div class="sub">${sub}</div>
|
|
450
|
+
</div>`;
|
|
451
|
+
const kpiCards = [
|
|
452
|
+
kpiCard(failedRuns === 0 ? "green" : "red", "Failed Runs", String(failedRuns), `of ${num(totalRuns)} runs · ${taskFailRate} task fail`),
|
|
453
|
+
kpiCard(completionColor, "Completion Rate", completionRate, `${num(completed)} / ${num(invoked)} invoked`),
|
|
454
|
+
kpiCard("neutral", "Median Duration", `${medianDurMin}m`, `p95 = ${p95DurMin}m`),
|
|
455
|
+
kpiCard("blue", "Total Promoted", num(cons.promoted), `avg ${avgPromoted} / run`),
|
|
456
|
+
kpiCard("blue", "MI Written", num(miWritten), `${miYieldRate} yield rate`),
|
|
457
|
+
kpiCard("purple", "Graph Entities", num(ge.entities), `+${num(ge.relations)} relations`),
|
|
458
|
+
kpiCard("neutral", "Stash Derived", num(improve.memorySummary.derived), `of ${num(improve.memorySummary.eligible)} eligible (whole-stash)`),
|
|
459
|
+
// #576: real LLM work — duration leads, tokens compact, not a GPU proxy.
|
|
460
|
+
kpiCard(llm.calls > 0 ? "purple" : "neutral", "🧠 LLM Work", `${llmTokensCompact} tok`, `${fmtMs(llm.totalDurationMs)} · ${num(llm.calls)} calls · ${compact(llm.reasoningTokens)} reasoning`),
|
|
461
|
+
kpiCard(semColor, "Semantic Search", semValue, esc(sem.detail), semStyle),
|
|
462
|
+
kpiCard(proposals.length > 0 ? "yellow" : "neutral", "Pending Proposals", String(proposals.length), `from ${esc(opts.window)} batch`),
|
|
463
|
+
].join("\n");
|
|
464
|
+
// ── Chart payload ──────────────────────────────────────────────────────────
|
|
465
|
+
const distillReasons = [...new Set(runs.flatMap((r) => Object.keys(r.distillByReason)))]
|
|
466
|
+
.filter((reason) => !DISTILL_REASONS_HIDDEN.has(reason))
|
|
467
|
+
.sort();
|
|
468
|
+
const runsJsConst = `const RUNS = ${JSON.stringify(runs)};`;
|
|
469
|
+
// ── Summary table rows ─────────────────────────────────────────────────────
|
|
470
|
+
// Optional 4th element = a glossary tooltip rendered as <abbr title>.
|
|
471
|
+
const summaryRows = [
|
|
472
|
+
["Task fail rate", taskFailRate, "flat"],
|
|
473
|
+
["Agent fail rate", agentFailRate, "flat"],
|
|
474
|
+
["Improve completion", `${num(completed)} / ${num(invoked)}`, "flat"],
|
|
475
|
+
[
|
|
476
|
+
"MI yield rate",
|
|
477
|
+
miYieldRate,
|
|
478
|
+
trend.decisionQuality,
|
|
479
|
+
"Memory-inference yield: share of considered candidates that produced a written fact.",
|
|
480
|
+
],
|
|
481
|
+
["MI written", num(miWritten), trend.outputVolume, "Memory-inference: facts written this window."],
|
|
482
|
+
["Consolidation promoted", num(cons.promoted), trend.outputVolume],
|
|
483
|
+
["Consolidation merged", num(cons.merged), "flat"],
|
|
484
|
+
["Consolidation deleted", num(cons.deleted), "flat"],
|
|
485
|
+
["Consolidation contradicted", num(cons.contradicted), "flat"],
|
|
486
|
+
[
|
|
487
|
+
"Consolidation judged: no action",
|
|
488
|
+
num(cons.judgedNoAction),
|
|
489
|
+
"flat",
|
|
490
|
+
"Candidates reviewed but intentionally left unchanged (the 'judgedNoAction' field).",
|
|
491
|
+
],
|
|
492
|
+
["Chunk failure", chunkFail, "flat"],
|
|
493
|
+
["Graph entities", num(ge.entities), "up"],
|
|
494
|
+
["Graph relations", num(ge.relations), "up"],
|
|
495
|
+
[
|
|
496
|
+
"Stash derived",
|
|
497
|
+
num(improve.memorySummary.derived),
|
|
498
|
+
"up",
|
|
499
|
+
"Whole-stash recount of derived assets at report time — not a per-run sum.",
|
|
500
|
+
],
|
|
501
|
+
["Median wall time", fmtMs(wallTime.medianMs), trend.latency],
|
|
502
|
+
["P95 wall time", fmtMs(wallTime.p95Ms), trend.latency],
|
|
503
|
+
// #576: real LLM accounting (replaces the GPU-time proxy).
|
|
504
|
+
["LLM calls", num(llm.calls), "flat"],
|
|
505
|
+
["LLM total tokens", num(llm.totalTokens), "flat"],
|
|
506
|
+
["LLM prompt tokens", num(llm.promptTokens), "flat"],
|
|
507
|
+
["LLM completion tokens", num(llm.completionTokens), "flat"],
|
|
508
|
+
[
|
|
509
|
+
"LLM reasoning tokens",
|
|
510
|
+
num(llm.reasoningTokens),
|
|
511
|
+
"flat",
|
|
512
|
+
"Tokens spent on model reasoning/thinking, billed separately from prompt and completion.",
|
|
513
|
+
],
|
|
514
|
+
["LLM wall time", fmtMs(llm.totalDurationMs), trend.latency],
|
|
515
|
+
];
|
|
516
|
+
const summaryRowsHtml = summaryRows
|
|
517
|
+
.map(([label, value, t, tip]) => {
|
|
518
|
+
const labelHtml = tip ? `<abbr title="${esc(tip)}">${esc(label)}</abbr>` : esc(label);
|
|
519
|
+
return (` <tr><td>${labelHtml}</td><td>${esc(value)}</td>` +
|
|
520
|
+
`<td class="trend ${trendClass(t)}">${trendLabel(t)}</td></tr>`);
|
|
521
|
+
})
|
|
522
|
+
.join("\n");
|
|
523
|
+
// ── Action Items (merged + de-duplicated advisories + what-to-watch) ────────
|
|
524
|
+
// Advisories and the old "what to watch" cards were built from the same data
|
|
525
|
+
// (result.advisories, sem-blocked, proposals, tail-latency, failed-runs). We
|
|
526
|
+
// collapse them into ONE prioritized, de-duplicated list (P1/P2/P3 + a
|
|
527
|
+
// remediation command per item), keyed so each concern appears exactly once.
|
|
528
|
+
const items = [];
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
const pushItem = (item) => {
|
|
531
|
+
if (seen.has(item.key))
|
|
532
|
+
return;
|
|
533
|
+
seen.add(item.key);
|
|
534
|
+
items.push(item);
|
|
535
|
+
};
|
|
536
|
+
// Hard advisories from the health check engine (own remediation in message).
|
|
537
|
+
for (const a of result.advisories) {
|
|
538
|
+
if (a.status !== "warn" && a.status !== "fail")
|
|
539
|
+
continue;
|
|
540
|
+
pushItem({
|
|
541
|
+
key: `advisory:${a.name}`,
|
|
542
|
+
prio: a.status === "fail" ? "P1" : "P2",
|
|
543
|
+
cls: a.status === "fail" ? "fail" : "warn",
|
|
544
|
+
title: esc(humanize(a.name)),
|
|
545
|
+
descHtml: esc(a.message),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// Failed runs in window.
|
|
549
|
+
if (failedRuns > 0) {
|
|
550
|
+
pushItem({
|
|
551
|
+
key: "failed-runs",
|
|
552
|
+
prio: "P1",
|
|
553
|
+
cls: "fail",
|
|
554
|
+
title: `${failedRuns} failed run${failedRuns === 1 ? "" : "s"} in window`,
|
|
555
|
+
descHtml: `Task fail rate ${esc(taskFailRate)}. Inspect failed runs (ok=false) for early-exit or harness errors.`,
|
|
556
|
+
remedy: `akm health --since=${opts.window} --group-by run`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// Semantic search blocked.
|
|
560
|
+
if (sem.blocked) {
|
|
561
|
+
pushItem({
|
|
562
|
+
key: "semantic-search-blocked",
|
|
563
|
+
prio: "P2",
|
|
564
|
+
cls: "warn",
|
|
565
|
+
title: "Semantic search blocked",
|
|
566
|
+
descHtml: `Embedding provider unreachable. ${esc(sem.detail)}. Curate falls back to keyword search — relevance scoring degraded.`,
|
|
567
|
+
remedy: "akm config show",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// Pending proposals to drain.
|
|
571
|
+
if (proposals.length > 0) {
|
|
572
|
+
const bySource = new Map();
|
|
573
|
+
for (const p of proposals)
|
|
574
|
+
bySource.set(p.source, (bySource.get(p.source) ?? 0) + 1);
|
|
575
|
+
const srcSummary = [...bySource.entries()]
|
|
576
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
577
|
+
.map(([source, count]) => `${count} via ${esc(source)}`)
|
|
578
|
+
.join(", ");
|
|
579
|
+
pushItem({
|
|
580
|
+
key: "drain-proposals",
|
|
581
|
+
prio: "P2",
|
|
582
|
+
cls: "warn",
|
|
583
|
+
title: `Drain ${proposals.length} pending proposal${proposals.length === 1 ? "" : "s"}`,
|
|
584
|
+
descHtml: `Proposals generated this batch (${srcSummary}). Review before the queue grows further.`,
|
|
585
|
+
remedy: "akm proposal list",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// High tail latency.
|
|
589
|
+
if (wallTime.p95Ms && wallTime.medianMs && wallTime.p95Ms / wallTime.medianMs > 2.5) {
|
|
590
|
+
pushItem({
|
|
591
|
+
key: "tail-latency",
|
|
592
|
+
prio: "P3",
|
|
593
|
+
cls: "warn",
|
|
594
|
+
title: `High tail latency: p95=${fmtMs(wallTime.p95Ms)}, median=${fmtMs(wallTime.medianMs)}`,
|
|
595
|
+
descHtml: "P95 is well above median. Consolidation/LLM phase dominates wall time on slow runs. " +
|
|
596
|
+
"Check for slow chunks or LLM rate limiting.",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
// Stale freshness.
|
|
600
|
+
if (isStale && totalRuns > 0) {
|
|
601
|
+
pushItem({
|
|
602
|
+
key: "stale",
|
|
603
|
+
prio: "P3",
|
|
604
|
+
cls: "warn",
|
|
605
|
+
title: "No recent improve runs",
|
|
606
|
+
descHtml: `Newest run is ${esc(latestRunHuman)} — older than the 6h freshness threshold. Check the improve scheduler/cron.`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
// Session-log notes (informational, lowest priority).
|
|
610
|
+
if (result.sessionLogAdvisories.length > 0) {
|
|
611
|
+
const patterns = result.sessionLogAdvisories
|
|
612
|
+
.slice(0, 6)
|
|
613
|
+
.map((p) => `<li>${esc(p.topic)}</li>`)
|
|
614
|
+
.join("");
|
|
615
|
+
pushItem({
|
|
616
|
+
key: "session-log-notes",
|
|
617
|
+
prio: "P3",
|
|
618
|
+
cls: "warn",
|
|
619
|
+
title: `${result.sessionLogAdvisories.length} session-log note(s) (informational)`,
|
|
620
|
+
descHtml: `<ul style="margin:4px 0 0 16px;padding:0;">${patterns}</ul>`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
items.sort((a, b) => PRIO_RANK[a.prio] - PRIO_RANK[b.prio]);
|
|
624
|
+
const actionItemsHtml = items.length > 0
|
|
625
|
+
? items.map(actionItemCard).join("\n")
|
|
626
|
+
: passCard("No action items", "All checks passed and nothing needs attention for this window.");
|
|
627
|
+
// ── Proposal rows ──────────────────────────────────────────────────────────
|
|
628
|
+
const proposalRowsHtml = proposals.length > 0
|
|
629
|
+
? proposals
|
|
630
|
+
.map((p, i) => {
|
|
631
|
+
const tagCls = p.source === "extract" ? "tag-extract" : "tag-consolidate";
|
|
632
|
+
const ts = p.createdAt.slice(0, 16).replace("T", " ");
|
|
633
|
+
return (`<tr><td>${i + 1}</td><td><code>${esc(p.ref)}</code></td>` +
|
|
634
|
+
`<td><span class="tag ${tagCls}">${esc(p.source)}</span></td>` +
|
|
635
|
+
`<td>${esc(ts)}</td></tr>`);
|
|
636
|
+
})
|
|
637
|
+
.join("\n")
|
|
638
|
+
: '<tr><td colspan="4" style="text-align:center;color:var(--muted);">No pending proposals</td></tr>';
|
|
639
|
+
// ── Commands used ──────────────────────────────────────────────────────────
|
|
640
|
+
const commandsHtml = [
|
|
641
|
+
` <div><span>akm health --since=${esc(opts.window)} --group-by run --format json</span></div>`,
|
|
642
|
+
` <div><span>akm health --since=${esc(opts.window)} --window-compare=${esc(opts.compare)} --format json</span></div>`,
|
|
643
|
+
" <div><span>akm proposal list</span></div>",
|
|
644
|
+
].join("\n");
|
|
645
|
+
return {
|
|
646
|
+
"%%ECHARTS_TAG%%": buildEchartsTag(opts),
|
|
647
|
+
"%%REPORT_TITLE%%": esc(reportTitle),
|
|
648
|
+
"%%WINDOW%%": esc(opts.window),
|
|
649
|
+
"%%SINCE_HUMAN%%": esc(sinceHuman),
|
|
650
|
+
"%%RUN_COUNT%%": num(totalRuns),
|
|
651
|
+
"%%STATUS_BADGE_HTML%%": `${statusBadge}\n ${failBadge}`,
|
|
652
|
+
"%%EXEC_SUMMARY_HTML%%": execSummary,
|
|
653
|
+
"%%KPI_CARDS_HTML%%": kpiCards,
|
|
654
|
+
"%%RUNS_JS_CONST%%": runsJsConst,
|
|
655
|
+
"%%DISTILL_REASONS_JSON%%": JSON.stringify(distillReasons),
|
|
656
|
+
"%%SLICE_OPTIONS_HTML%%": buildSliceOptions(opts.window),
|
|
657
|
+
"%%LLM_BY_STAGE_JSON%%": JSON.stringify(llm.byStage ?? {}),
|
|
658
|
+
"%%SUMMARY_ROWS_HTML%%": summaryRowsHtml,
|
|
659
|
+
"%%ACTION_ITEMS_HTML%%": actionItemsHtml,
|
|
660
|
+
"%%PROPOSAL_ROWS_HTML%%": proposalRowsHtml,
|
|
661
|
+
"%%PROPOSAL_COUNT%%": String(proposals.length),
|
|
662
|
+
"%%COMMANDS_HTML%%": commandsHtml,
|
|
663
|
+
"%%GENERATED_AT%%": esc(generatedAt),
|
|
664
|
+
"%%AKM_VERSION%%": esc(pkgVersion),
|
|
665
|
+
};
|
|
666
|
+
}
|