akm-cli 0.9.14 → 0.9.15-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  4. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  5. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  6. package/dist/assets/tasks/core/improve.yml +1 -1
  7. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  8. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  9. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  13. package/dist/cli/retired-commands.js +0 -1
  14. package/dist/cli/shared.js +9 -0
  15. package/dist/cli/unknown-flags.js +1 -0
  16. package/dist/cli.js +3 -2
  17. package/dist/commands/config-cli.js +85 -3
  18. package/dist/commands/env/env-cli.js +1 -42
  19. package/dist/commands/env/env.js +1 -1
  20. package/dist/commands/env/secret-cli.js +1 -2
  21. package/dist/commands/health/checks.js +357 -63
  22. package/dist/commands/health/engine-usage.js +45 -0
  23. package/dist/commands/health/improve-metrics.js +18 -0
  24. package/dist/commands/health/llm-usage.js +41 -1
  25. package/dist/commands/health/plugin-staleness.js +7 -3
  26. package/dist/commands/health/version-drift.js +93 -0
  27. package/dist/commands/health/windows.js +3 -1
  28. package/dist/commands/health.js +44 -9
  29. package/dist/commands/improve/consolidate/chunking.js +4 -2
  30. package/dist/commands/improve/improve-cli.js +99 -5
  31. package/dist/commands/improve/improve-report.js +154 -0
  32. package/dist/commands/improve/improve-result-file.js +45 -33
  33. package/dist/commands/improve/improve-strategies.js +133 -3
  34. package/dist/commands/improve/improve-usage-report.js +182 -0
  35. package/dist/commands/improve/improve.js +40 -3
  36. package/dist/commands/improve/locks.js +27 -78
  37. package/dist/commands/improve/planner.js +1 -0
  38. package/dist/commands/improve/preparation.js +9 -1
  39. package/dist/commands/improve/reflect.js +44 -4
  40. package/dist/commands/models-cli.js +50 -1
  41. package/dist/commands/proposal/repository.js +8 -3
  42. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  43. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  44. package/dist/commands/read/search-cli.js +38 -2
  45. package/dist/commands/read/show.js +103 -4
  46. package/dist/commands/sources/info.js +5 -1
  47. package/dist/commands/sources/self-update.js +2 -2
  48. package/dist/commands/sources/stash-cli.js +31 -0
  49. package/dist/commands/tasks/tasks-cli.js +49 -2
  50. package/dist/commands/workflow-cli.js +86 -12
  51. package/dist/core/asset/markdown-fragments.js +35 -0
  52. package/dist/core/config/config-schema.js +14 -0
  53. package/dist/core/config/config.js +302 -24
  54. package/dist/core/env-secret-ref.js +58 -5
  55. package/dist/core/errors.js +30 -0
  56. package/dist/core/improve-result.js +51 -0
  57. package/dist/core/loopback.js +17 -0
  58. package/dist/core/paths.js +11 -0
  59. package/dist/core/run-lock.js +96 -0
  60. package/dist/core/sensitive-marker-path.js +19 -0
  61. package/dist/core/state-db.js +74 -14
  62. package/dist/indexer/index-rebuild-lock.js +73 -0
  63. package/dist/indexer/index-writer-lock.js +40 -1
  64. package/dist/indexer/index-written-assets.js +21 -1
  65. package/dist/indexer/indexer.js +18 -17
  66. package/dist/indexer/materialize-embeddings.js +282 -32
  67. package/dist/indexer/search/db-search.js +49 -2
  68. package/dist/integrations/agent/engine-resolution.js +96 -6
  69. package/dist/integrations/agent/execution-definitions.js +6 -15
  70. package/dist/integrations/agent/execution-lowering.js +6 -1
  71. package/dist/integrations/agent/execution-preparation.js +1 -1
  72. package/dist/integrations/agent/model-map.js +123 -20
  73. package/dist/integrations/agent/prompts.js +40 -8
  74. package/dist/integrations/agent/runner-dispatch.js +9 -3
  75. package/dist/integrations/agent/runner.js +2 -0
  76. package/dist/llm/client.js +8 -3
  77. package/dist/llm/embedder.js +20 -8
  78. package/dist/llm/embedders/local.js +10 -2
  79. package/dist/llm/embedders/remote.js +188 -21
  80. package/dist/output/shapes/helpers.js +38 -2
  81. package/dist/output/shapes/models-list.js +16 -0
  82. package/dist/output/shapes/passthrough.js +2 -0
  83. package/dist/output/shapes.js +4 -0
  84. package/dist/output/text/command-format.js +29 -0
  85. package/dist/output/text/helpers.js +1 -1
  86. package/dist/output/text/improve-report.js +27 -0
  87. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  88. package/dist/output/text/show-format.js +4 -0
  89. package/dist/output/text.js +4 -0
  90. package/dist/scripts/akm-migrate-node.js +24798 -21732
  91. package/dist/scripts/akm-migrate.js +23408 -20343
  92. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  93. package/dist/storage/repositories/index-fts-repository.js +49 -6
  94. package/dist/storage/repositories/index-vec-repository.js +30 -0
  95. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  96. package/dist/tasks/backends/cron.js +14 -7
  97. package/dist/tasks/run/run-workflow-task.js +16 -0
  98. package/dist/workflows/exec/child-workflow.js +2 -2
  99. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  100. package/dist/workflows/exec/run-workflow.js +6 -5
  101. package/dist/workflows/runtime/runs.js +33 -5
  102. package/docs/migration/release-notes/0.9.15.md +52 -0
  103. package/docs/migration/release-notes/README.md +4 -0
  104. package/docs/reference/cli.md +245 -29
  105. package/docs/reference/configuration.md +180 -19
  106. package/docs/reference/data-and-telemetry.md +8 -0
  107. package/docs/reference/tasks.md +16 -1
  108. package/docs/reference/workflow-schema.md +5 -1
  109. package/package.json +1 -1
  110. package/schemas/akm-config.json +8 -0
@@ -0,0 +1,45 @@
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
+ * `engine-last-used` support for `akm health` (#950).
6
+ *
7
+ * An engine bound to an enabled improve process that has not actually been
8
+ * invoked in weeks looks identical to a healthy one on every existing check —
9
+ * `default-llm-engine`/`configured-engines` only probe availability/reachability,
10
+ * never USE. This module answers "when did this engine last run, and for
11
+ * whom" by folding `llm_usage` events over a fixed lookback window,
12
+ * independent of the report's `--since` (mirrors `session-extraction`'s
13
+ * independent `SESSION_EXTRACTION_LEDGER_WINDOW_DAYS` window in `checks.ts`).
14
+ */
15
+ import { daysToMs } from "../../core/common.js";
16
+ import { readEvents } from "../../core/events.js";
17
+ import { LLM_USAGE_EVENT } from "../../llm/usage-persist.js";
18
+ import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
19
+ /** Rolling window `engine-last-used` reads over, independent of `--since`. */
20
+ export const ENGINE_LAST_USED_LOOKBACK_DAYS = 30;
21
+ /** ISO cutoff for the `ENGINE_LAST_USED_LOOKBACK_DAYS` window, ending at `now()`. */
22
+ export function engineLastUsedSince(now) {
23
+ return new Date(now() - daysToMs(ENGINE_LAST_USED_LOOKBACK_DAYS)).toISOString();
24
+ }
25
+ /**
26
+ * Fold `llm_usage` events over the lookback window into the most recent call
27
+ * per engine. Best-effort like every other health probe: an event with no
28
+ * `engine` field (pre-#576 rows, or a call outside any resolved engine) is
29
+ * skipped rather than bucketed under a synthetic key.
30
+ */
31
+ export function readLastEngineUsage(stateDbPath, now) {
32
+ const since = engineLastUsedSince(now);
33
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events;
34
+ const lastUsed = new Map();
35
+ for (const event of events) {
36
+ const record = decodeLlmUsageRecord(event.metadata);
37
+ if (!record?.engine)
38
+ continue;
39
+ const existing = lastUsed.get(record.engine);
40
+ if (!existing || event.ts > existing.lastUsedAt) {
41
+ lastUsed.set(record.engine, { lastUsedAt: event.ts, process: record.process });
42
+ }
43
+ }
44
+ return lastUsed;
45
+ }
@@ -44,6 +44,24 @@ export function taskFailureDetail(row) {
44
44
  export function isAgentTaskHistoryRow(row) {
45
45
  return row.target_kind === "command";
46
46
  }
47
+ /**
48
+ * #943: reason-value breakdown for a set of agent (command-kind) task
49
+ * failure rows — how much of the observed failures are `timeout` vs
50
+ * `non_zero_exit` vs `spawn_failed` etc., so `akm health`'s `task-fail-rate`
51
+ * advisory can say "timeout-dominant" from data rather than log grep. Keeps
52
+ * the existing `AgentFailureReason` vocabulary verbatim (spawn.ts) — a
53
+ * reason-per-row read failure (already warned by {@link taskFailureDetail})
54
+ * still counts under `"unknown"` rather than being dropped, so the total
55
+ * always equals `agentFailures.length`.
56
+ */
57
+ export function countAgentFailureReasons(agentFailures) {
58
+ const counts = {};
59
+ for (const row of agentFailures) {
60
+ const reason = String(taskFailureDetail(row)?.reason ?? "unknown");
61
+ counts[reason] = (counts[reason] ?? 0) + 1;
62
+ }
63
+ return counts;
64
+ }
47
65
  function createUnknownImproveMetrics() {
48
66
  return {
49
67
  invoked: 0,
@@ -8,7 +8,7 @@
8
8
  import { readEvents } from "../../core/events.js";
9
9
  import { LLM_USAGE_EVENT } from "../../llm/usage-persist.js";
10
10
  import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
11
- /** Stage key used for `llm_usage` events recorded outside any stage scope. */
11
+ /** Stage/process/engine/model key used for `llm_usage` events recorded with no value for that dimension. */
12
12
  const UNATTRIBUTED_STAGE = "unattributed";
13
13
  function emptyLlmUsageStageAggregate() {
14
14
  return {
@@ -18,6 +18,7 @@ function emptyLlmUsageStageAggregate() {
18
18
  completionTokens: 0,
19
19
  totalTokens: 0,
20
20
  reasoningTokens: 0,
21
+ failures: 0,
21
22
  };
22
23
  }
23
24
  /** A zeroed aggregate — also the value health reports when it could not read state.db at all (#791). */
@@ -52,6 +53,8 @@ export function summarizeLlmUsage(events) {
52
53
  target.completionTokens += record.completionTokens ?? 0;
53
54
  target.totalTokens += record.totalTokens ?? 0;
54
55
  target.reasoningTokens += record.reasoningTokens ?? 0;
56
+ if (record.outcome === "error")
57
+ target.failures += 1;
55
58
  }
56
59
  }
57
60
  return aggregate;
@@ -64,3 +67,40 @@ export function readLlmUsageAggregate(stateDbPath, since, until) {
64
67
  });
65
68
  return summarizeLlmUsage(events);
66
69
  }
70
+ /**
71
+ * Aggregate `llm_usage` events (#576) into a process x engine x model
72
+ * cross-tab (#944) — one row per distinct `(process, engine, model)` triple
73
+ * seen, each carrying the same call/failure/token/duration totals as
74
+ * {@link LlmUsageStageAggregate}. A call missing any one of the three
75
+ * dimensions is keyed under {@link UNATTRIBUTED_STAGE} for that dimension
76
+ * (never dropped). Does not fold into, or change the shape of,
77
+ * {@link summarizeLlmUsage}'s existing `byStage`/`byProcess`/`byEngine`
78
+ * breakdowns — `akm health`'s existing consumers of those stay untouched.
79
+ * Row order is insertion order (first `(process, engine, model)` triple seen).
80
+ */
81
+ export function summarizeLlmUsageCrossTab(events) {
82
+ const rows = new Map();
83
+ for (const event of events) {
84
+ const record = decodeLlmUsageRecord(event.metadata);
85
+ if (!record)
86
+ continue;
87
+ const process = record.process ?? UNATTRIBUTED_STAGE;
88
+ const engine = record.engine ?? UNATTRIBUTED_STAGE;
89
+ const model = record.model ?? UNATTRIBUTED_STAGE;
90
+ const key = `${process}:${engine}:${model}`;
91
+ let row = rows.get(key);
92
+ if (!row) {
93
+ row = { process, engine, model, ...emptyLlmUsageStageAggregate() };
94
+ rows.set(key, row);
95
+ }
96
+ row.calls += 1;
97
+ row.totalDurationMs += record.durationMs;
98
+ row.promptTokens += record.promptTokens ?? 0;
99
+ row.completionTokens += record.completionTokens ?? 0;
100
+ row.totalTokens += record.totalTokens ?? 0;
101
+ row.reasoningTokens += record.reasoningTokens ?? 0;
102
+ if (record.outcome === "error")
103
+ row.failures += 1;
104
+ }
105
+ return [...rows.values()];
106
+ }
@@ -29,15 +29,19 @@
29
29
  * and there was previously no way to know that from the CLI side.
30
30
  *
31
31
  * Read-only: this never fetches, writes, or mutates the plugin cache or
32
- * marketplace clone. Check 2 is the one deliberate exception to "`akm
33
- * health` makes no network call" (see `./health-advisories.md`): a plugin's
32
+ * marketplace clone. Check 2 is one of two deliberate exceptions to "`akm
33
+ * health` makes no network call" (see `./health-advisories.md`; the other is
34
+ * the `cli-version` advisory in `./version-drift.ts`, #950): a plugin's
34
35
  * local marketplace clone is not proof of what is newest upstream — the
35
36
  * incident above involved a clone that hadn't seen the fix's tag at all — so
36
37
  * the only way to ever detect drift is to ask the remote what tags exist.
37
38
  * That query is a `git ls-remote --tags` (lists refs; fetches nothing,
38
39
  * writes nothing) with a short timeout, and any failure (offline, no
39
40
  * remote, timeout) degrades to "installed version reported, no staleness
40
- * claim" rather than a false positive or a hang.
41
+ * claim" rather than a false positive or a hang. Unlike `cli-version`, this
42
+ * check is NOT gated behind `--probe`/`--no-probe` — it predates that flag
43
+ * and stays unconditional; see `./version-drift.ts` for why the newer check
44
+ * chose the opposite default.
41
45
  *
42
46
  * Every collector here is best-effort and silent on missing/unreadable
43
47
  * input: no Claude plugin installed, no marketplace clone, an unreadable
@@ -0,0 +1,93 @@
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
+ * `cli-version` advisory for `akm health` (#950).
6
+ *
7
+ * A fleet running a mix of akm-cli versions can look healthy on every other
8
+ * check while quietly running stale code (the motivating case: a host on
9
+ * 0.9.12 next to four peers already on 0.9.14, with nothing in `akm health`
10
+ * calling that out). This closes that gap by reusing {@link checkForUpdate}
11
+ * — the exact "installed vs GitHub latest release" comparison `akm upgrade`
12
+ * already trusts — rather than adding a second, parallel "ask npm" query
13
+ * that could disagree with it during the gap between a GitHub release and
14
+ * its npm publish step.
15
+ *
16
+ * Modelled 1:1 on `plugin-staleness.ts`'s shape: an injectable network seam,
17
+ * best-effort, silent-on-failure (never a false positive, never a hang — the
18
+ * network call is bounded to {@link CLI_VERSION_CHECK_TIMEOUT_MS} with no
19
+ * retries, not `checkForUpdate`'s own 30s/3-retry defaults, which are tuned
20
+ * for the deliberate, explicit `akm upgrade` command rather than a
21
+ * `--probe`-default-on health check). This is the SECOND deliberate network
22
+ * exception in `akm health` (see
23
+ * `plugin-staleness.ts` and `docs/architecture/internals/health-advisories.md`)
24
+ * — unlike plugin-staleness's unconditional `git ls-remote`, this one is
25
+ * gated behind the same `--probe`/`--no-probe` flag the engine-reachability
26
+ * checks already use, so an air-gapped host's existing `--no-probe` habit
27
+ * suppresses it too, and offline/rate-limited failures degrade to `unknown`
28
+ * (never a false `warn`).
29
+ */
30
+ import { pkgVersion } from "../../version.js";
31
+ import { checkForUpdate } from "../sources/self-update.js";
32
+ /**
33
+ * Bound on the `checkForUpdate` network call. `checkForUpdate`'s own
34
+ * defaults (30s timeout, up to 3 retries with exponential backoff) are
35
+ * tuned for `akm upgrade` — a deliberate, explicit human command where a
36
+ * long wait is acceptable. `akm health --probe` defaults to `true` and is
37
+ * not such a gate, so this advisory overrides both to match the bounded,
38
+ * single-attempt discipline every other network-touching health check uses
39
+ * (`plugin-staleness.ts`'s 5s `git ls-remote`, `probeLlmEndpoint`'s 3s
40
+ * `AbortSignal.timeout`): a stale or air-gapped host must degrade to
41
+ * `unknown` in seconds, never block `akm health` for minutes.
42
+ */
43
+ const CLI_VERSION_CHECK_TIMEOUT_MS = 5_000;
44
+ /**
45
+ * Build the `cli-version` advisory. `probe` mirrors the engine-reachability
46
+ * checks' `--probe`/`--no-probe` gating: only network when `true`; otherwise
47
+ * `unknown` with "not probed", matching the un-probed engine-reachability
48
+ * message shape rather than silently omitting the check.
49
+ */
50
+ export async function collectVersionDriftAdvisory(probe, deps = {}) {
51
+ const cliVersion = deps.cliVersion ?? pkgVersion;
52
+ if (!probe) {
53
+ return {
54
+ name: "cli-version",
55
+ kind: "deterministic",
56
+ status: "unknown",
57
+ confidence: "high",
58
+ message: `akm v${cliVersion} is installed. Version-drift was not probed.`,
59
+ evidence: { installedVersion: cliVersion },
60
+ };
61
+ }
62
+ try {
63
+ const result = await (deps.checkForUpdate ?? checkForUpdate)(cliVersion, {
64
+ timeout: CLI_VERSION_CHECK_TIMEOUT_MS,
65
+ retries: 0,
66
+ });
67
+ return {
68
+ name: "cli-version",
69
+ kind: "deterministic",
70
+ status: result.updateAvailable ? "warn" : "pass",
71
+ confidence: "high",
72
+ message: result.updateAvailable
73
+ ? `akm v${cliVersion} is installed; v${result.latestVersion} is available — run 'akm upgrade'.`
74
+ : `akm v${cliVersion} is installed and up to date.`,
75
+ evidence: { installedVersion: cliVersion, latestVersion: result.latestVersion },
76
+ };
77
+ }
78
+ catch (error) {
79
+ // Offline, rate-limited, or a malformed release response — never a false
80
+ // "stale" or "up to date" claim, only "could not be checked".
81
+ return {
82
+ name: "cli-version",
83
+ kind: "deterministic",
84
+ status: "unknown",
85
+ confidence: "high",
86
+ message: `akm v${cliVersion} is installed; the update check could not reach the release source.`,
87
+ evidence: {
88
+ installedVersion: cliVersion,
89
+ error: error instanceof Error ? error.constructor.name : "UnknownError",
90
+ },
91
+ };
92
+ }
93
+ }
@@ -11,7 +11,7 @@ import { readEvents } from "../../core/events.js";
11
11
  import { buildTaskRunId, getLoggedRunIds } from "../../core/logs-db.js";
12
12
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
13
13
  import { queryTaskHistory } from "../../storage/repositories/task-history-repository.js";
14
- import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./improve-metrics.js";
14
+ import { buildImproveSkipSummary, computeWallTimeStats, countAgentFailureReasons, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./improve-metrics.js";
15
15
  import { readLlmUsageAggregate } from "./llm-usage.js";
16
16
  import { computeDegradationMetrics, computeDenominatorFixedCoverage } from "./metrics.js";
17
17
  import { buildPerRunSummaries } from "./task-runs.js";
@@ -156,6 +156,7 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
156
156
  const logBackingRate = taskRowsWithLogs.length === 0 ? 1 : existingLogRows.length / taskRowsWithLogs.length;
157
157
  const taskFailRate = taskRows.length === 0 ? 0 : failedTaskRows.length / taskRows.length;
158
158
  const agentFailureRate = agentRows.length === 0 ? 0 : agentFailures.length / agentRows.length;
159
+ const agentFailureReasonCounts = countAgentFailureReasons(agentFailures);
159
160
  const improveInvoked = readEvents({ since, type: "improve_invoked" }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < new Date(until).getTime()).length;
160
161
  const improveCompletedEvents = readEvents({ since, type: IMPROVE_COMPLETED_EVENT }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < new Date(until).getTime());
161
162
  const improveSkippedEvents = readEvents({ since, type: "improve_skipped" }, { dbPath: stateDbPath }).events.filter((event) => new Date(event.ts ?? since).getTime() < new Date(until).getTime());
@@ -185,6 +186,7 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
185
186
  const metrics = {
186
187
  taskFailRate: roundRate(taskFailRate),
187
188
  agentFailureRate: roundRate(agentFailureRate),
189
+ agentFailureReasonCounts,
188
190
  stuckActiveRuns,
189
191
  logBackingRate: roundRate(logBackingRate),
190
192
  probeRoundTripMs: null,
@@ -14,14 +14,16 @@ import { listExistingTableNames, listPendingStateMigrations, openStateDatabase }
14
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
15
15
  import { probeLlmEndpoint } from "../llm/client.js";
16
16
  import { getExtractOutcomeCountsSince } from "../storage/repositories/extract-sessions-repository.js";
17
+ import { countImproveRunsSince } from "../storage/repositories/improve-runs-repository.js";
17
18
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
18
19
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
19
20
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
20
21
  import { pkgVersion } from "../version.js";
21
22
  import { collectImproveAdvisories } from "./health/advisories.js";
22
- import { HEALTH_CHECKS, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
23
+ import { HEALTH_CHECKS, probeActiveImproveStrategy, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
23
24
  import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
24
- import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
25
+ import { engineLastUsedSince, readLastEngineUsage } from "./health/engine-usage.js";
26
+ import { buildImproveSkipSummary, computeWallTimeStats, countAgentFailureReasons, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
25
27
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
26
28
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
27
29
  import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
@@ -30,6 +32,7 @@ import { collectSurfacesAdvisories } from "./health/surfaces.js";
30
32
  import { buildPerRunSummaries } from "./health/task-runs.js";
31
33
  import { buildTypeDirectoryAdvisory } from "./health/type-directory-check.js";
32
34
  import { ACTIVE_RUN_WARN_MS, IMPROVE_COMPLETED_EVENT, MIN_ROWS_FOR_WORST_TASK_FAIL_RATE, } from "./health/types.js";
35
+ import { collectVersionDriftAdvisory } from "./health/version-drift.js";
33
36
  import { buildWindowMetrics, computeDeltas, partitionLogBackedRows, resolveWindowCompare } from "./health/windows.js";
34
37
  const DEFAULT_SINCE_MS = 24 * 60 * 60 * 1000;
35
38
  export function parseHealthSince(since) {
@@ -150,23 +153,28 @@ function gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now) {
150
153
  taskFailRate,
151
154
  worstTaskFailRate: computeWorstTaskFailRate(taskRows),
152
155
  agentFailureRate,
156
+ agentFailureReasonCounts: countAgentFailureReasons(agentFailures),
153
157
  };
154
158
  }
155
159
  /**
156
- * Config fields the surfaces advisory needs. Best-effort: an unloadable
157
- * config leaves the field undefined and the caller falls back to a generic
158
- * message.
160
+ * Config fields the surfaces advisory and (#949) the `thinking-control`
161
+ * check need. Best-effort: an unloadable config leaves both fields at their
162
+ * empty fallback and the callers degrade to their generic/unknown states.
159
163
  */
160
164
  function gatherEgressConfigPhase() {
161
165
  let egressConfigView;
166
+ let thinkingOffEngines = [];
162
167
  try {
163
168
  const config = loadConfig();
164
169
  egressConfigView = config;
170
+ thinkingOffEngines = Object.entries(config.engines ?? {})
171
+ .filter(([, engine]) => engine.kind === "llm" && engine.enableThinking === false)
172
+ .map(([name]) => name);
165
173
  }
166
174
  catch {
167
- // fall through with undefined
175
+ // fall through with undefined/empty
168
176
  }
169
- return { egressConfigView };
177
+ return { egressConfigView, thinkingOffEngines };
170
178
  }
171
179
  /** Extract-ledger outcome counts for the `session-extraction` check's window, independent of `--since`. */
172
180
  function gatherSessionExtractionLedgerPhase(db, now) {
@@ -451,6 +459,7 @@ function degradedStateDbReport(hardCheck, options) {
451
459
  metrics: {
452
460
  taskFailRate: 0,
453
461
  agentFailureRate: 0,
462
+ agentFailureReasonCounts: {},
454
463
  stuckActiveRuns: 0,
455
464
  logBackingRate: 0,
456
465
  // `null`, not 0: the round-trip probe did not run, which is not the same
@@ -529,13 +538,30 @@ export async function akmHealth(options = {}) {
529
538
  // Network probes overlap the local database phases below; awaited where consumed.
530
539
  const engineProbesPromise = runHealthEngineProbes({ probeReachable: options.probe ? probeLlmEndpoint : undefined });
531
540
  engineProbesPromise.catch(() => undefined);
541
+ // #950: same best-effort, --probe-gated discipline as engineProbesPromise
542
+ // above — started here, alongside it, and awaited later.
543
+ const versionDriftPromise = collectVersionDriftAdvisory(Boolean(options.probe), { cliVersion: pkgVersion });
544
+ versionDriftPromise.catch(() => undefined);
532
545
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
533
546
  const { tableNames, missingTables, probe } = taskHistory;
534
- const { egressConfigView } = gatherEgressConfigPhase();
547
+ const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
535
548
  const { improveSummary } = gatherImproveSummaryPhase(db, stateDbPath, since, now);
536
549
  advisories.push(...gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, options, egressConfigView));
537
550
  const sessionExtractionLedger = gatherSessionExtractionLedgerPhase(db, now);
551
+ // #950: computed once (no IO beyond config/env, same as gatherEgressConfigPhase)
552
+ // so `active-improve-strategy` and `engine-last-used` project the same
553
+ // process→engine map instead of each resolving the strategy independently.
554
+ const { check: activeImproveStrategy, processEngines: activeImproveStrategyEngines } = probeActiveImproveStrategy();
555
+ // #950: `engine-last-used` reads a fixed lookback window independent of
556
+ // `--since` (mirrors sessionExtractionLedger's independent window above).
557
+ const engineLastUsedSinceIso = engineLastUsedSince(now);
558
+ const engineLastUsed = readLastEngineUsage(stateDbPath, now);
559
+ const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
538
560
  const engineProbes = await engineProbesPromise;
561
+ const versionDrift = await versionDriftPromise;
562
+ // Read once, shared by the `thinking-control` check (#949) and the
563
+ // `metrics.llmUsage` report field below — same window, same aggregate.
564
+ const llmUsage = readLlmUsageAggregate(stateDbPath, since);
539
565
  // Run the ordered health-check registry. Each check projects the shared
540
566
  // context computed above into one HealthCheckResult; `channel` routes it to
541
567
  // hardChecks or advisories. Declaration order in HEALTH_CHECKS is the
@@ -554,10 +580,18 @@ export async function akmHealth(options = {}) {
554
580
  stuckActiveRuns: taskHistory.stuckActiveRuns,
555
581
  stuckActiveTasks: taskHistory.stuckActiveTasks,
556
582
  worstTaskFailRate: taskHistory.worstTaskFailRate,
583
+ agentFailureReasonCounts: taskHistory.agentFailureReasonCounts,
557
584
  sessionExtraction: improveSummary.sessionExtraction,
558
585
  sessionExtractionLedger,
559
586
  autoAccept: improveSummary.autoAccept,
560
587
  engineProbes,
588
+ thinkingOffEngines,
589
+ llmUsage,
590
+ versionDrift,
591
+ activeImproveStrategy,
592
+ activeImproveStrategyEngines,
593
+ engineLastUsed,
594
+ improveRunsInLookbackWindow,
561
595
  };
562
596
  for (const check of HEALTH_CHECKS) {
563
597
  const result = check.run(checkContext);
@@ -569,10 +603,11 @@ export async function akmHealth(options = {}) {
569
603
  const metrics = {
570
604
  taskFailRate: roundRate(taskHistory.taskFailRate),
571
605
  agentFailureRate: roundRate(taskHistory.agentFailureRate),
606
+ agentFailureReasonCounts: taskHistory.agentFailureReasonCounts,
572
607
  stuckActiveRuns: taskHistory.stuckActiveRuns,
573
608
  logBackingRate: roundRate(taskHistory.logBackingRate),
574
609
  probeRoundTripMs: probe.durationMs,
575
- llmUsage: readLlmUsageAggregate(stateDbPath, since),
610
+ llmUsage,
576
611
  };
577
612
  const hardFailure = hardChecks.some((check) => check.status === "fail");
578
613
  const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
@@ -10,9 +10,11 @@ import { cacheHash } from "../content-hash.js";
10
10
  /**
11
11
  * Conservative chars-per-token estimate used when computing prompt budgets.
12
12
  * English text averages roughly 4 chars/token for most LLM tokenizers. We use
13
- * 3 to stay conservative (shorter tokens = more tokens per char).
13
+ * 3 to stay conservative (shorter tokens = more tokens per char). Exported so
14
+ * other context-length-driven budgets (e.g. reflect's direct-LLM content cap,
15
+ * #952) share this single estimate instead of redeclaring it.
14
16
  */
15
- const CHARS_PER_TOKEN = 3;
17
+ export const CHARS_PER_TOKEN = 3;
16
18
  /**
17
19
  * Overhead budget reserved for the system prompt, chunk header lines, and per-
18
20
  * memory metadata lines (name, description, tags, separator). Measured at
@@ -8,7 +8,7 @@ import { getStringArg, parsePositiveIntFlag } from "../../cli/parse-args.js";
8
8
  import { GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
9
9
  import { isFullRefInput, parseRefInput } from "../../core/asset/resolve-ref.js";
10
10
  import { loadConfig } from "../../core/config/config.js";
11
- import { UsageError } from "../../core/errors.js";
11
+ import { ConfigError, UsageError } from "../../core/errors.js";
12
12
  import { resolveMutationTarget } from "../../core/mutation-target.js";
13
13
  import { getCacheDir } from "../../core/paths.js";
14
14
  import { redactSensitiveText } from "../../core/redaction.js";
@@ -16,9 +16,11 @@ import { clearLogFile, setLogFile, warn } from "../../core/warn.js";
16
16
  import { resolveWriteTarget } from "../../core/write-source.js";
17
17
  import { collectEngineCredentialValues } from "../../integrations/agent/engine-resolution.js";
18
18
  import { akmImprove } from "./improve.js";
19
+ import { runImproveReportQuery } from "./improve-report.js";
19
20
  import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
20
21
  import { runImproveSession } from "./improve-session.js";
21
22
  import { resolveImprovePlan } from "./improve-strategies.js";
23
+ import { formatUsageReportTable } from "./improve-usage-report.js";
22
24
  let akmImproveForRun = akmImprove;
23
25
  /** Swap the CLI's improve work implementation in deterministic subprocess tests. */
24
26
  export function _setAkmImproveForTests(fake) {
@@ -74,6 +76,57 @@ function rejectRetiredImproveTargetFlag() {
74
76
  return;
75
77
  throw new UsageError("`akm improve --target` was renamed to `--bundle` in 0.9. Use `--bundle <name>` instead.", "INVALID_FLAG_VALUE");
76
78
  }
79
+ /**
80
+ * `--require-engines` (#957): abort before any lock, log, or index side
81
+ * effect when the resolved plan already knows a process the active strategy
82
+ * would enable cannot run. Without this flag improve degrades gracefully —
83
+ * it skips the affected processes and reports them in `skippedProcesses` —
84
+ * which is right for an interactive run but wrong for a scheduled one that
85
+ * would rather fail loudly than burn its budget re-indexing and then skip
86
+ * everything. Names the unresolved credential reference per process (not
87
+ * just the process name) so an operator whose own shell passes config
88
+ * validation can see exactly what the scheduler's environment is missing.
89
+ */
90
+ function assertRequiredEnginesAvailable(plan) {
91
+ if (plan.engineUnavailable.length === 0)
92
+ return;
93
+ const lines = plan.engineUnavailable.map((item) => ` - ${item.process} (${item.configKey}): ${item.reason}`);
94
+ throw new ConfigError(`--require-engines: ${plan.engineUnavailable.length} improve process${plan.engineUnavailable.length === 1 ? "" : "es"} cannot run because ${plan.engineUnavailable.length === 1 ? "its" : "their"} engine is unavailable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
95
+ }
96
+ /**
97
+ * `akm improve report` (#944): a scope value that dispatches to the per-run
98
+ * LLM usage/routing report instead of a real improve run — "report" is not,
99
+ * and will never be, a real asset type (`DEFAULT_ALLOWED_TYPES` in
100
+ * improve-strategies.ts), so this already matched zero assets before this
101
+ * flag existed, matching the precedent `rejectRetiredCanaryScope` set for
102
+ * intercepting a special scope word ahead of any lock/log/index side effect.
103
+ */
104
+ function runImproveReportCli(args) {
105
+ const runIdArg = getStringArg(args, "run");
106
+ const sinceArg = getStringArg(args, "since");
107
+ const result = runImproveReportQuery({ runId: runIdArg, since: sinceArg });
108
+ output("improve-report", { ok: true, ...result });
109
+ }
110
+ /**
111
+ * `--run`/`--since` only mean anything with the "report" scope, which
112
+ * intercepts before this point in the `run` handler below. citty is
113
+ * non-strict, so passing either with a real scope (or no scope at all) used
114
+ * to be silently ignored — the flag's value was read nowhere else, and the
115
+ * run proceeded as an ordinary improve run with no error, discarding the
116
+ * operator's intent. Reject explicitly instead, matching the precedent
117
+ * `rejectRetiredCanaryScope`/`rejectRetiredImproveTargetFlag` set for other
118
+ * flag misuse on this command.
119
+ */
120
+ function rejectReportOnlyFlags(args) {
121
+ const flag = getStringArg(args, "run") !== undefined
122
+ ? "--run"
123
+ : getStringArg(args, "since") !== undefined
124
+ ? "--since"
125
+ : undefined;
126
+ if (flag === undefined)
127
+ return;
128
+ throw new UsageError(`\`${flag}\` only applies to \`akm improve report\`. Use \`akm improve report ${flag} <value>\` instead.`, "INVALID_FLAG_VALUE");
129
+ }
77
130
  export const improveCommand = defineCommand({
78
131
  meta: {
79
132
  name: "improve",
@@ -91,6 +144,11 @@ export const improveCommand = defineCommand({
91
144
  },
92
145
  task: { type: "string", description: "Add extra guidance for this improvement pass" },
93
146
  "dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
147
+ plan: {
148
+ type: "boolean",
149
+ description: "Alias for --dry-run (#947). Sets the exact same internal flag; use it when previewing resolved process -> engine -> model routing (plan.processes) rather than checking what would write.",
150
+ default: false,
151
+ },
94
152
  bundle: { type: "string", description: "Override the write target for accepted proposals" },
95
153
  limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
96
154
  "timeout-ms": {
@@ -112,6 +170,19 @@ export const improveCommand = defineCommand({
112
170
  description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
113
171
  default: false,
114
172
  },
173
+ "require-engines": {
174
+ type: "boolean",
175
+ description: "Abort before any indexing, lock, or log side effect (exit 78) if the active strategy would enable a process whose engine or credential cannot be resolved in this process's environment. Without this flag, improve degrades gracefully instead: it skips the affected processes and reports them in the result's skippedProcesses. Recommended alongside --skip-if-locked for scheduled runs.",
176
+ default: false,
177
+ },
178
+ run: {
179
+ type: "string",
180
+ description: 'Only with the "report" scope (`akm improve report --run <id>`): show the LLM usage/routing report for one specific improve_runs row instead of the most recent run. Mutually exclusive with --since.',
181
+ },
182
+ since: {
183
+ type: "string",
184
+ description: 'Only with the "report" scope (`akm improve report --since <window>`): aggregate the LLM usage/routing report over every real run started since <window> (a duration like "24h"/"7d", or an ISO timestamp) instead of showing one run. Mutually exclusive with --run.',
185
+ },
115
186
  strategy: {
116
187
  type: "string",
117
188
  description: "Named improve strategy from improve.strategies or built-in strategies (catchup, consolidate, default, graph-refresh, proactive-maintenance, quick, reflect-distill, thorough). Controls which sub-processes run and which asset types are processed.",
@@ -127,6 +198,13 @@ export const improveCommand = defineCommand({
127
198
  },
128
199
  async run({ args }) {
129
200
  await runWithJsonErrors(async () => {
201
+ // #944 — dispatch before any lock/log/index side effect, same
202
+ // interception point as rejectRetiredCanaryScope below.
203
+ if (getStringArg(args, "scope") === "report") {
204
+ runImproveReportCli(args);
205
+ return;
206
+ }
207
+ rejectReportOnlyFlags(args);
130
208
  rejectRetiredImproveTargetFlag();
131
209
  // D7 — `--format` used to be rejected here outright. It is a global flag on
132
210
  // a command that does emit an envelope through `output()` (always on
@@ -137,7 +215,9 @@ export const improveCommand = defineCommand({
137
215
  const jsonToStdout = args["json-to-stdout"];
138
216
  const targetArg = getStringArg(args, "bundle");
139
217
  const taskArg = getStringArg(args, "task");
140
- const dryRun = args["dry-run"];
218
+ // #947 `--plan` is a zero-logic discoverability alias for `--dry-run`;
219
+ // it must never fork the computation, only set the same flag.
220
+ const dryRun = args["dry-run"] || args.plan;
141
221
  const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
142
222
  const timeoutMs = parsePositiveIntFlag(args["timeout-ms"], "--timeout-ms");
143
223
  const requireFeedbackSignal = args["require-feedback-signal"];
@@ -154,7 +234,13 @@ export const improveCommand = defineCommand({
154
234
  : resolveWriteTarget(effectiveConfig, targetArg);
155
235
  // Resolve every enabled model-backed process before logging, signal
156
236
  // lifecycle setup, or any filesystem/database side effect.
157
- const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig);
237
+ // #800/#957 round 3 — `--dry-run`/`--plan` never dispatches, so the
238
+ // "no improve process can run" guard must not throw when every process
239
+ // is disabled purely by an unreachable credential; a live run keeps
240
+ // throwing (allowAllDisabled unset).
241
+ const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
242
+ if (args["require-engines"])
243
+ assertRequiredEnginesAvailable(resolvedPlan);
158
244
  const selectedStrategyName = resolvedPlan.strategy.name;
159
245
  const sensitiveValues = collectEngineCredentialValues(effectiveConfig);
160
246
  // Only set the keys the user actually passed (citty leaves the flag
@@ -271,8 +357,9 @@ export const improveCommand = defineCommand({
271
357
  // `improve_runs` table of state.db (migration 003) and emit NOTHING
272
358
  // on stdout. The verbose JSON would otherwise scroll earlier progress
273
359
  // logs out of the terminal buffer. The existing `[improve] ...`
274
- // progress log lines on stderr remain the canonical console UX —
275
- // do NOT add any new console output here.
360
+ // progress log lines on stderr remain the canonical console UX — the
361
+ // usage-report table below (#944) follows that same convention
362
+ // (stderr, `[improve]`-prefixed), it is not new stdout noise.
276
363
  //
277
364
  // Pre-0.8.0 wrote `<stash>/.akm/runs/<run-id>/improve-result.json`;
278
365
  // those files are no longer authored. Query recent runs with:
@@ -295,6 +382,13 @@ export const improveCommand = defineCommand({
295
382
  else {
296
383
  process.stderr.write(`warning: no writable bundle directory resolved; improve result not persisted to state.db (use --json-to-stdout to capture)\n`);
297
384
  }
385
+ // #944 — same table `akm improve report` renders, appended to every
386
+ // real (non-dry-run) run so an operator sees the routing/cost split
387
+ // without a separate command. Omitted when the run made no LLM calls
388
+ // and skipped no enabled process (nothing to report).
389
+ if (improveResult.usageReport) {
390
+ process.stderr.write(`${formatUsageReportTable(improveResult.usageReport)}\n`);
391
+ }
298
392
  if (jsonToStdout)
299
393
  output("improve", improveResult);
300
394
  // F4: was `process.exit(0)` — the run has already been fully recorded