akm-cli 0.9.15-beta.2 → 0.9.15-beta.4

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 CHANGED
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [0.9.15-beta.2] - 2026-09-09
7
+ ## [0.9.15-beta.4] - 2026-09-10
8
8
 
9
9
  ### Added
10
10
 
@@ -250,8 +250,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
250
250
  `embedding.contextLength` is Ollama's `num_ctx` only now — it used to also
251
251
  silently set the per-request token budget (`embedding.maxTokens`), so
252
252
  setting it for the server's context window changed request batching too.
253
- The request budget is `embedding.maxTokens` (default 8000), so a request
254
- carries about 16 documents alongside the new per-document cap by default.
253
+ The request budget is `embedding.maxTokens` (default 6000, see #954
254
+ below), so a request carries about 11 documents alongside the new
255
+ per-document cap by default.
255
256
  - **`akm index` reports where its embedding credential came from, before the
256
257
  first provider request (#953).** A field report suspected a gateway was
257
258
  receiving unauthenticated embedding requests despite `embedding.apiKey`
@@ -268,6 +269,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
268
269
  apiKey`, or `none configured` — never the credential's value, so a field
269
270
  run can compare it directly against what the gateway actually logged.
270
271
  `--verbose` also names the config file the run loaded.
272
+ - **`akm health` gains a `scheduler-binary` advisory for scheduler binary
273
+ drift (#953).** A field report found `akm task sync`'s recorded absolute
274
+ akm path can go stale after upgrading through a different installer (npm
275
+ global to a standalone download, or vice versa), leaving a scheduled run
276
+ invoking the old binary indefinitely with nothing surfacing it. The new
277
+ `--probe`-gated advisory reads the scheduler's recorded akm invocation —
278
+ the same binding `task sync`/`task doctor` already read, no crontab text
279
+ parsing — runs it with `--version`, and `warn`s naming both versions when
280
+ it differs from the running CLI, pointing at `akm task sync` as the
281
+ remedy. `unknown` when not probed, no task is installed, or the recorded
282
+ binary cannot be executed.
283
+ - **`akm improve <ref> --show-prompt` prints the composed reflect prompt for
284
+ one asset and exits (#952).** A beta.3 field round confirmed the #952 prompt
285
+ fix by reading source, but no live `akm improve` completed across three
286
+ attempts, leaving no cheap way to see the prompt in practice. `--show-prompt`
287
+ reuses every read-only step `akm improve`'s live reflect step already
288
+ performs (source resolution, runner selection, feedback/schema-hint/
289
+ related-lesson/rejected-proposal gathering) and stops before the dispatch
290
+ lease reflect would otherwise acquire — no lock, index write, or engine call,
291
+ same as `--dry-run`. Requires a fully-qualified asset ref as the scope; JSON/
292
+ yaml output carries the prompt as a `prompt` field, text output prints it
293
+ directly so the #952 framing (feedback shown as an unverified report, and
294
+ the instruction never to emit the truncation marker or out-of-asset content)
295
+ can be checked by eye.
271
296
 
272
297
  ### Changed
273
298
 
@@ -322,6 +347,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
322
347
  single document that still fails this way is skipped, as
323
348
  `context-window-exceeded`; every other failure (network error, 5xx, malformed
324
349
  response) keeps the prior skip-the-whole-batch behavior.
350
+ - **The default per-request token budget is lower, and adapts mid-run after a
351
+ context-size rejection (#954, field report on beta.1).**
352
+ `embedding.maxTokens`'s default dropped from 8000 to 6000: the 4-chars-per-
353
+ token estimator undercounts dense technical text by 7-55%, so 8000 regularly
354
+ overshot a real 8192-token endpoint. On an `akm index` run's first
355
+ context-size rejection, the effective request budget additionally shrinks to
356
+ three quarters of its current value (floored at twice
357
+ `embedding.maxInputTokens`) for every request not yet sent, and one
358
+ default-level line reports the new value; it never shrinks a second time in
359
+ the same run. Users who set `embedding.maxTokens` explicitly keep it as the
360
+ starting point but still benefit from this same-run recovery.
325
361
  - **Embedding requests are dispatched through a small in-flight window instead of
326
362
  strictly sequentially (#954).** The window defaults to 1 request at a time for
327
363
  a loopback endpoint and 2 for a remote one; the actual throughput knob is
@@ -432,6 +468,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
432
468
  Source-cache hydration (which runs before `index.db` is even opened) now
433
469
  reports its own progress the same way: `Hydrating source i/n: <name>` per
434
470
  source, plus a 15s heartbeat while a sync is in flight.
471
+ - **`embedding.chunkSize` is retired (#954).** Nothing under `src/` ever read
472
+ it; it was declared in the config schema but had no effect. It is removed
473
+ from `EmbeddingConnectionConfigSchema` and `schemas/akm-config.json`. The
474
+ `embedding` object stays `.passthrough()`, so a config that still sets
475
+ `embedding.chunkSize` keeps loading exactly as before — the key is simply
476
+ ignored, not rejected or warned about.
435
477
 
436
478
  ### Fixed
437
479
 
@@ -533,6 +575,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
533
575
  physical batch size", which the existing context-size pattern
534
576
  (`exceed_context_size_error`, "context size", …) did not match, so the
535
577
  whole batch was dropped instead of being split and retried like a 413.
578
+ - **The end-of-run throughput line now sums the capped text actually sent to
579
+ the embedding provider (#954).** `storedTokens` accumulated
580
+ `estimateTokenCount(entry.searchText)` — the entry's pre-cap search text —
581
+ while the request `embedBatch` received held the text `capEmbeddingText`
582
+ had already truncated to `embedding.maxInputTokens`, so the reported
583
+ `tokens/s` figure overstated throughput for every entry over the cap. The
584
+ final line now sums the estimate of the capped text the batching loop
585
+ already built, matching what the provider was actually asked to embed.
536
586
  - **A `kill <launcher-pid>` no longer orphans the running `akm` process
537
587
  (#956).** The published launcher (`scripts/node-runtime/akm`/
538
588
  `akm-migrate`) now forwards SIGTERM/SIGINT/SIGHUP to its bun/node child
@@ -562,6 +612,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
562
612
  instead of reusing them. A plain `akm index` resume after an interruption
563
613
  now embeds only the entries still missing a vector, with no purge and no
564
614
  canary.
615
+ - **A concurrent `akm index` without `--skip-if-locked` now fails with a
616
+ retryable-shortly exit code instead of a raw driver error (#956).**
617
+ Contention with another writer touching index.db (a second `akm index`, a
618
+ source add/update's embedding pass, the per-command background reindex)
619
+ used to exhaust the SQLite driver's retry window and surface as
620
+ `{"ok":false,"error":"database is locked"}` at exit 70
621
+ (internal/unclassified). It is now reclassified into a `TransientError`
622
+ with a dedicated `INDEX_DB_CONTENDED` code (exit 75), naming the rebuild
623
+ lock's live holder pid when known, mirroring `STATE_DB_CONTENDED`'s
624
+ precedent (#948) for state.db; the original driver text survives as
625
+ `cause`. `--skip-if-locked` is unaffected — it already skips gracefully
626
+ before ever attempting the write.
627
+ - **A concurrent plain `akm index` (no `--skip-if-locked`) could still exit
628
+ 78 instead of 75, a 2026-09-10 field re-test found (#956).** Two `akm
629
+ index` runs colliding on the short internal barrier that registers the
630
+ opt-in rebuild lock (shared with every other akm lock/lease) threw
631
+ `ConfigError("INVALID_CONFIG_FILE")` — a config-error exit that tells a
632
+ supervisor to stop retrying, when this is ordinary contention between two
633
+ legitimate runs. The barrier is meant to be held only milliseconds, so it
634
+ now retries briefly (a bounded, jittered backoff) before giving up, letting
635
+ an ordinary collision succeed instead of erroring at all; if it is still
636
+ busy after that, it raises `TransientError` with a dedicated
637
+ `MAINTENANCE_BARRIER_BUSY` code (exit 75) instead of the config error. The
638
+ rebuild lock itself is unaffected and still never blocks (#872).
639
+ - **The fingerprint-rename canary embeds the exact text the stored vector was
640
+ generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
641
+ the entry's raw `search_text`, while the main embedding pass caps it to
642
+ `embedding.maxInputTokens` before ever calling the provider — so for any
643
+ entry whose search text exceeded the cap, the canary's freshly re-embedded
644
+ vector came from a different input than the one that produced the stored
645
+ vector, and the median cosine similarity could fall below the compatibility
646
+ threshold for reasons unrelated to the model, triggering a needless full
647
+ purge and rebuild on a same-model rename. The canary now caps each sampled
648
+ entry's search text the same way, through the same `capEmbeddingText`
649
+ helper, before requesting its vector.
650
+ - **`akm improve --require-engines` now probes reachability instead of only
651
+ checking config/credentials, and a dead engine can no longer hang a run
652
+ past its timeout or a signal (#957).** Field, beta.3: engines pointed at a
653
+ dead endpoint, then `akm improve --require-engines` ran ~4 minutes with
654
+ zero output, ignoring an external `timeout 30` (SIGTERM) and akm's own
655
+ `--timeout-ms` — the documented exit-78 "required engines unavailable"
656
+ path could never be observed, because `--require-engines` only checked
657
+ that an engine was configured and credentialed, never whether it actually
658
+ answered. It now also runs the same bounded reachability probe `akm
659
+ health`'s `default-llm-engine`/`configured-engines` checks already use
660
+ (one `/models` request per distinct endpoint), before any lock, log, or
661
+ index side effect, and aborts at exit 78 naming the unreachable engine and
662
+ endpoint. Separately, a live run now prints one default-level line if it
663
+ has waited more than a few seconds on its first engine response, so a
664
+ scheduled run's log is never silently empty while an engine is slow or
665
+ dead — `--timeout-ms` and an engine's own configured timeout already
666
+ aborted the in-flight request correctly (confirmed by this investigation,
667
+ not changed), and SIGTERM/SIGINT already ended the process within its
668
+ documented grace period.
669
+ - **`akm improve --show-prompt` now includes `avoidPatterns` when a live
670
+ improve loop has set them (#952).** The preview built its own second copy
671
+ of reflect's prompt-source gathering and `ReflectPromptInput` assembly,
672
+ which had already drifted from the real dispatch path: it never read
673
+ `avoidPatterns` (recent-error context from earlier assets in the same
674
+ run), so the preview was not always the prompt a live iteration would
675
+ actually send. Both the preview and the real dispatch path (`akmReflect`,
676
+ `runReflectRefineIterations`) now gather sources and assemble the prompt
677
+ input through the same two shared helpers, so this cannot drift again.
565
678
 
566
679
  ## [0.9.14] - 2026-09-04
567
680
 
package/dist/cli.js CHANGED
@@ -295,8 +295,8 @@ const healthCommand = defineCommand({
295
295
  probe: {
296
296
  type: "boolean",
297
297
  default: true,
298
- description: "Probe default-llm-engine / configured-engines reachability and check for a newer akm release (on by default).",
299
- negativeDescription: "Skip the reachability probes and the update check (for an offline or air-gapped host).",
298
+ description: "Probe default-llm-engine / configured-engines reachability, check for a newer akm release, and check the scheduler's recorded akm binary version (on by default).",
299
+ negativeDescription: "Skip the reachability probes, the update check, and the scheduler-binary version check (for an offline or air-gapped host).",
300
300
  },
301
301
  },
302
302
  async run({ args }) {
@@ -556,7 +556,7 @@ export const main = defineCommand({
556
556
  " 2 usage error\n" +
557
557
  " 4 health warn (akm health only)\n" +
558
558
  " 70 internal / unclassified error\n" +
559
- " 75 transient (retry shortly — another akm process holds a lock or is writing state.db)\n" +
559
+ " 75 transient (retry shortly — another akm process holds a lock or is writing state.db or index.db)\n" +
560
560
  " 78 config error",
561
561
  },
562
562
  args: {
@@ -12,6 +12,7 @@ import { withEngineFallback } from "../../integrations/agent/engine-fallback.js"
12
12
  import { isLlmCredentialAvailable, resolveEngine, } from "../../integrations/agent/engine-resolution.js";
13
13
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
14
14
  import { loadModelMap, mergeModelMapLayers, parseModelMapLayer, readInstalledModelMapText, resolveModelMapAlias, userModelMapPath, } from "../../integrations/agent/model-map.js";
15
+ import { probeEndpointOnce } from "../../llm/client.js";
15
16
  import { listKeys } from "../env/env.js";
16
17
  import { resolveImprovePlan } from "../improve/improve-strategies.js";
17
18
  import { ENGINE_LAST_USED_LOOKBACK_DAYS } from "./engine-usage.js";
@@ -20,13 +21,7 @@ import { ACTIVE_RUN_WARN_MS, TASK_FAIL_RATE_WARN, } from "./types.js";
20
21
  function probeConnectionReachable(connection, deps, cache) {
21
22
  if (!deps.probeReachable)
22
23
  return Promise.resolve(undefined);
23
- const key = connection.endpoint.replace(/\/+$/, "");
24
- let pending = cache.get(key);
25
- if (!pending) {
26
- pending = deps.probeReachable(connection);
27
- cache.set(key, pending);
28
- }
29
- return pending;
24
+ return probeEndpointOnce(connection, cache, deps.probeReachable);
30
25
  }
31
26
  function reachabilityEvidence(reach) {
32
27
  return reach
@@ -1164,11 +1159,20 @@ export const HEALTH_CHECKS = [
1164
1159
  run: (ctx) => ctx.versionDrift,
1165
1160
  },
1166
1161
  {
1167
- // #950: registered last order is load-bearing (see the HEALTH_CHECKS
1168
- // doc comment above). Advisory channel, `kind: "deterministic"` — same
1162
+ // #950: advisory channel, `kind: "deterministic"` same
1169
1163
  // exit-code-gating rationale as thinking-control above.
1170
1164
  name: "engine-last-used",
1171
1165
  channel: "advisory",
1172
1166
  run: (ctx) => projectEngineLastUsedCheck(ctx.activeImproveStrategyEngines, ctx.engineLastUsed, ctx.improveRunsInLookbackWindow, ENGINE_LAST_USED_LOOKBACK_DAYS),
1173
1167
  },
1168
+ {
1169
+ // #953: registered last — order is load-bearing (see the HEALTH_CHECKS
1170
+ // doc comment above). Best-effort scheduler-binary-drift advisory, gated
1171
+ // behind the same --probe/--no-probe flag as engine reachability and
1172
+ // cli-version. Computed once in health.ts (process-spawn IO), projected
1173
+ // here like versionDrift/engineProbes.
1174
+ name: "scheduler-binary",
1175
+ channel: "advisory",
1176
+ run: (ctx) => ctx.schedulerBinaryDrift,
1177
+ },
1174
1178
  ];
@@ -0,0 +1,120 @@
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
+ * `scheduler-binary` advisory for `akm health` (#953).
6
+ *
7
+ * `akm task sync` records an absolute akm invocation path in the OS
8
+ * scheduler (`src/tasks/resolve-akm-bin.ts`) because cron/launchd/schtasks
9
+ * all run jobs with a minimal PATH. A field report showed that path going
10
+ * stale after upgrading akm through a different installer than the one
11
+ * active at the last `task sync` (e.g. npm-global to a standalone binary):
12
+ * the schedule kept invoking a version behind, with nothing in `akm health`
13
+ * surfacing it, until an unrelated failure (a rejected `secret://` engine
14
+ * key on the stale binary) surfaced the drift.
15
+ *
16
+ * Modelled 1:1 on `version-drift.ts`'s shape: an injectable seam,
17
+ * best-effort, `--probe`-gated so an air-gapped host's `--no-probe` habit
18
+ * suppresses this too, and `unknown` on any uncertainty rather than a false
19
+ * `pass`/`warn`. Reads the scheduler's recorded binding via
20
+ * `SchedulerBackend.list()` — the same reader `akm task doctor` uses — never
21
+ * a second crontab/launchd/schtasks text parser.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import { selectBackend } from "../../tasks/backends/index.js";
25
+ import { pkgVersion } from "../../version.js";
26
+ /**
27
+ * Bound on the scheduler-recorded binary's `--version` probe. Matches the
28
+ * `--version` timeout the engine-reachability checks already use
29
+ * (checks.ts's `runConfiguredEngineProbe`), so a wedged or missing binary
30
+ * degrades this advisory to `unknown` in seconds rather than blocking
31
+ * `akm health --probe`.
32
+ */
33
+ const SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS = 5_000;
34
+ /**
35
+ * Build the `scheduler-binary` advisory. `probe` mirrors the
36
+ * engine-reachability and `cli-version` checks' `--probe`/`--no-probe`
37
+ * gating: only inspects the scheduler and spawns a process when `true`;
38
+ * otherwise `unknown` with "not probed", never touching the OS scheduler.
39
+ */
40
+ export async function collectSchedulerBinaryAdvisory(probe, deps = {}) {
41
+ const cliVersion = deps.cliVersion ?? pkgVersion;
42
+ if (!probe) {
43
+ return {
44
+ name: "scheduler-binary",
45
+ kind: "deterministic",
46
+ status: "unknown",
47
+ confidence: "high",
48
+ message: "Scheduler binary version drift was not probed.",
49
+ };
50
+ }
51
+ let installed;
52
+ try {
53
+ installed = await (deps.backend ?? selectBackend()).list();
54
+ }
55
+ catch (error) {
56
+ return {
57
+ name: "scheduler-binary",
58
+ kind: "deterministic",
59
+ status: "unknown",
60
+ confidence: "high",
61
+ message: `Installed scheduled tasks could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
62
+ };
63
+ }
64
+ // task sync rewrites every installed binding to the same current
65
+ // invocation atomically, so the first entry's binding represents the
66
+ // whole schedule under normal operation.
67
+ const [firstInstalled] = installed;
68
+ const [binaryPath, ...leadingArgs] = firstInstalled?.binding ?? [];
69
+ if (!firstInstalled || !binaryPath) {
70
+ return {
71
+ name: "scheduler-binary",
72
+ kind: "deterministic",
73
+ status: "unknown",
74
+ confidence: "high",
75
+ message: "No scheduled task is installed.",
76
+ };
77
+ }
78
+ const binding = firstInstalled.binding;
79
+ const run = deps.spawnSync ?? spawnSync;
80
+ let scheduledVersion;
81
+ try {
82
+ const result = run(binaryPath, [...leadingArgs, "--version"], {
83
+ encoding: "utf8",
84
+ timeout: SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS,
85
+ });
86
+ if ((result.status ?? 1) === 0)
87
+ scheduledVersion = result.stdout?.trim() || undefined;
88
+ }
89
+ catch {
90
+ scheduledVersion = undefined;
91
+ }
92
+ if (!scheduledVersion) {
93
+ return {
94
+ name: "scheduler-binary",
95
+ kind: "deterministic",
96
+ status: "unknown",
97
+ confidence: "high",
98
+ message: "The scheduler's recorded akm binary could not be executed.",
99
+ evidence: { binding },
100
+ };
101
+ }
102
+ if (scheduledVersion === cliVersion) {
103
+ return {
104
+ name: "scheduler-binary",
105
+ kind: "deterministic",
106
+ status: "pass",
107
+ confidence: "high",
108
+ message: `Scheduled tasks are bound to akm v${scheduledVersion}, matching the running CLI.`,
109
+ evidence: { binding, scheduledVersion, cliVersion },
110
+ };
111
+ }
112
+ return {
113
+ name: "scheduler-binary",
114
+ kind: "deterministic",
115
+ status: "warn",
116
+ confidence: "high",
117
+ message: `Scheduled tasks are bound to akm v${scheduledVersion}, but the running CLI is v${cliVersion} — run \`akm task sync\` to rebind the schedule.`,
118
+ evidence: { binding, scheduledVersion, cliVersion },
119
+ };
120
+ }
@@ -27,6 +27,7 @@ import { buildImproveSkipSummary, computeWallTimeStats, countAgentFailureReasons
27
27
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
28
28
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
29
29
  import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
30
+ import { collectSchedulerBinaryAdvisory } from "./health/scheduler-binary.js";
30
31
  import { collectStashExposureAdvisory } from "./health/stash-exposure.js";
31
32
  import { collectSurfacesAdvisories } from "./health/surfaces.js";
32
33
  import { buildPerRunSummaries } from "./health/task-runs.js";
@@ -542,6 +543,12 @@ export async function akmHealth(options = {}) {
542
543
  // above — started here, alongside it, and awaited later.
543
544
  const versionDriftPromise = collectVersionDriftAdvisory(Boolean(options.probe), { cliVersion: pkgVersion });
544
545
  versionDriftPromise.catch(() => undefined);
546
+ // #953: same --probe-gated, best-effort discipline as versionDriftPromise
547
+ // above.
548
+ const schedulerBinaryDriftPromise = collectSchedulerBinaryAdvisory(Boolean(options.probe), {
549
+ cliVersion: pkgVersion,
550
+ });
551
+ schedulerBinaryDriftPromise.catch(() => undefined);
545
552
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
546
553
  const { tableNames, missingTables, probe } = taskHistory;
547
554
  const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
@@ -559,6 +566,7 @@ export async function akmHealth(options = {}) {
559
566
  const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
560
567
  const engineProbes = await engineProbesPromise;
561
568
  const versionDrift = await versionDriftPromise;
569
+ const schedulerBinaryDrift = await schedulerBinaryDriftPromise;
562
570
  // Read once, shared by the `thinking-control` check (#949) and the
563
571
  // `metrics.llmUsage` report field below — same window, same aggregate.
564
572
  const llmUsage = readLlmUsageAggregate(stateDbPath, since);
@@ -592,6 +600,7 @@ export async function akmHealth(options = {}) {
592
600
  activeImproveStrategyEngines,
593
601
  engineLastUsed,
594
602
  improveRunsInLookbackWindow,
603
+ schedulerBinaryDrift,
595
604
  };
596
605
  for (const check of HEALTH_CHECKS) {
597
606
  const result = check.run(checkContext);
@@ -15,12 +15,16 @@ import { redactSensitiveText } from "../../core/redaction.js";
15
15
  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
- import { akmImprove } from "./improve.js";
18
+ import { probeEndpointOnce, probeLlmEndpoint } from "../../llm/client.js";
19
+ import { getOutputMode } from "../../output/context.js";
20
+ import { deliverRendered } from "../../output/html-render.js";
21
+ import { akmImprove, resolveImproveReadSource } from "./improve.js";
19
22
  import { runImproveReportQuery } from "./improve-report.js";
20
23
  import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
21
24
  import { runImproveSession } from "./improve-session.js";
22
- import { resolveImprovePlan } from "./improve-strategies.js";
25
+ import { resolveImprovePlan, } from "./improve-strategies.js";
23
26
  import { formatUsageReportTable } from "./improve-usage-report.js";
27
+ import { renderReflectPromptPreview } from "./reflect.js";
24
28
  let akmImproveForRun = akmImprove;
25
29
  /** Swap the CLI's improve work implementation in deterministic subprocess tests. */
26
30
  export function _setAkmImproveForTests(fake) {
@@ -93,6 +97,84 @@ function assertRequiredEnginesAvailable(plan) {
93
97
  const lines = plan.engineUnavailable.map((item) => ` - ${item.process} (${item.configKey}): ${item.reason}`);
94
98
  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
99
  }
100
+ /**
101
+ * Every distinct `kind: "llm"` connection the active strategy's plan would
102
+ * actually dispatch against — the main per-process runners plus triage's own
103
+ * judgment engine, which is resolved separately (#957).
104
+ */
105
+ function collectRequiredEngineTargets(plan) {
106
+ const targets = [];
107
+ for (const [processName, process] of Object.entries(plan.processes)) {
108
+ if (process.runner) {
109
+ targets.push({ process: processName, engine: process.runner.engine, connection: process.runner.connection });
110
+ }
111
+ }
112
+ if (plan.triageJudgment?.kind === "llm") {
113
+ targets.push({
114
+ process: "triage.judgment",
115
+ engine: plan.triageJudgment.engine,
116
+ connection: plan.triageJudgment.connection,
117
+ });
118
+ }
119
+ return targets;
120
+ }
121
+ /**
122
+ * `--require-engines` field re-test (#957): the static check above only
123
+ * proves an engine is configured and credentialed — it cannot see a dead
124
+ * endpoint. A field run against an unreachable engine sat silent for
125
+ * minutes instead of hitting the documented exit-78 path. Reuse the SAME
126
+ * bounded reachability probe `akm health`'s `default-llm-engine` /
127
+ * `configured-engines` checks already run (`probeLlmEndpoint`, a single
128
+ * `/models` GET bounded by its own default timeout) once per distinct
129
+ * endpoint (via the shared `probeEndpointOnce` memoization health/checks.ts
130
+ * also uses), so a dead engine is caught here instead of during dispatch.
131
+ */
132
+ async function assertRequiredEnginesReachable(plan, probeReachable = probeLlmEndpoint) {
133
+ const targets = collectRequiredEngineTargets(plan);
134
+ if (targets.length === 0)
135
+ return;
136
+ const probesByEndpoint = new Map();
137
+ const probed = await Promise.all(targets.map(async (target) => ({
138
+ ...target,
139
+ reach: await probeEndpointOnce(target.connection, probesByEndpoint, probeReachable),
140
+ })));
141
+ const unreachable = probed.filter((item) => !item.reach.reachable);
142
+ if (unreachable.length === 0)
143
+ return;
144
+ const lines = unreachable.map((item) => ` - ${item.process} (engine "${item.engine}", ${item.connection.endpoint}): ${item.reach.error ?? "did not respond"}`);
145
+ throw new ConfigError(`--require-engines: ${unreachable.length} improve process${unreachable.length === 1 ? "" : "es"} cannot run because ${unreachable.length === 1 ? "its" : "their"} engine endpoint is not reachable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
146
+ }
147
+ /**
148
+ * `--show-prompt` (#952): render the composed reflect prompt for one asset ref
149
+ * and exit, before any lock, log, index write, or engine dispatch — the field
150
+ * had no cheap way to confirm the #952 prompt fix (unverified-feedback framing,
151
+ * no-truncation-marker instruction) without running a full improve cycle.
152
+ * Reuses `renderReflectPromptPreview` (reflect.ts), which stops before the
153
+ * dispatch lease reflect would otherwise acquire, so this never calls an engine.
154
+ */
155
+ async function runShowPromptCli(refArg, parsedRef, taskArg, targetArg, resolvedPlan) {
156
+ const readSource = resolveImproveReadSource(resolvedPlan.config, parsedRef, targetArg);
157
+ const preview = await renderReflectPromptPreview({
158
+ ref: refArg,
159
+ ...(taskArg ? { task: taskArg } : {}),
160
+ improveProfile: resolvedPlan.strategy.config,
161
+ config: resolvedPlan.config,
162
+ stashDir: readSource.source.path,
163
+ });
164
+ const outputMode = getOutputMode();
165
+ if (outputMode.format === "text") {
166
+ deliverRendered(preview.prompt, outputMode.outputPath);
167
+ return;
168
+ }
169
+ output("improve", {
170
+ schemaVersion: 2,
171
+ ok: true,
172
+ ref: preview.ref,
173
+ engine: preview.engine,
174
+ engineKind: preview.engineKind,
175
+ prompt: preview.prompt,
176
+ });
177
+ }
96
178
  /**
97
179
  * `akm improve report` (#944): a scope value that dispatches to the per-run
98
180
  * LLM usage/routing report instead of a real improve run — "report" is not,
@@ -172,7 +254,12 @@ export const improveCommand = defineCommand({
172
254
  },
173
255
  "require-engines": {
174
256
  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.",
257
+ 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, OR whose endpoint fails a bounded reachability probe (the same probe akm health runs). 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.",
258
+ default: false,
259
+ },
260
+ "show-prompt": {
261
+ type: "boolean",
262
+ description: "Print the composed reflect prompt for one asset ref and exit — no lock, index write, or engine dispatch (#952). Requires a fully-qualified asset ref as the scope positional (e.g. `akm improve lessons/my-lesson --show-prompt`). JSON/yaml format carries the prompt as a `prompt` field; text format prints it directly.",
176
263
  default: false,
177
264
  },
178
265
  run: {
@@ -216,8 +303,10 @@ export const improveCommand = defineCommand({
216
303
  const targetArg = getStringArg(args, "bundle");
217
304
  const taskArg = getStringArg(args, "task");
218
305
  // #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;
306
+ // it must never fork the computation, only set the same flag. #952 —
307
+ // `--show-prompt` implies the same read-only posture (it never reaches
308
+ // akmImprove at all, but keeps writeTarget/resolvedPlan unset the same way).
309
+ const dryRun = args["dry-run"] || args.plan || args["show-prompt"];
221
310
  const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
222
311
  const timeoutMs = parsePositiveIntFlag(args["timeout-ms"], "--timeout-ms");
223
312
  const requireFeedbackSignal = args["require-feedback-signal"];
@@ -239,8 +328,20 @@ export const improveCommand = defineCommand({
239
328
  // is disabled purely by an unreachable credential; a live run keeps
240
329
  // throwing (allowAllDisabled unset).
241
330
  const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
242
- if (args["require-engines"])
331
+ // #952 — same interception point as the `report` scope above: before any
332
+ // lock, log, or index side effect. Requires a single fully-qualified
333
+ // asset ref (not a type or whole-bundle scope).
334
+ if (args["show-prompt"]) {
335
+ if (!scopeArg || !scopeRef) {
336
+ throw new UsageError("`--show-prompt` requires a fully-qualified asset ref as the scope (e.g. `akm improve lessons/my-lesson --show-prompt`).", "INVALID_FLAG_VALUE");
337
+ }
338
+ await runShowPromptCli(scopeArg, scopeRef, taskArg, targetArg, resolvedPlan);
339
+ return;
340
+ }
341
+ if (args["require-engines"]) {
243
342
  assertRequiredEnginesAvailable(resolvedPlan);
343
+ await assertRequiredEnginesReachable(resolvedPlan);
344
+ }
244
345
  const selectedStrategyName = resolvedPlan.strategy.name;
245
346
  const sensitiveValues = collectEngineCredentialValues(effectiveConfig);
246
347
  // Only set the keys the user actually passed (citty leaves the flag
@@ -65,6 +65,19 @@ export function renderSyncCommitMessage(template, result, nowMs) {
65
65
  };
66
66
  return template.replace(/\{(\w+)\}/g, (match, key) => tokens[key] ?? match);
67
67
  }
68
+ /**
69
+ * How long the improve loop waits for its FIRST engine response (success or
70
+ * error — any terminal record proves the run is not silent) before printing
71
+ * one default-level line. The timer is armed once the triage/index prepass
72
+ * finishes and the loop is about to start dispatching engine requests — not
73
+ * at run start — so it measures engine latency, not prepass time. Field
74
+ * re-test (#957): an engine pointed at a dead endpoint produced zero output
75
+ * for minutes, so a genuine hang looked identical to a normal-but-slow run.
76
+ * A few seconds is short enough that an operator watching a scheduled run's
77
+ * live log sees something promptly, long enough that an ordinary fast
78
+ * response never prints it.
79
+ */
80
+ export const FIRST_ENGINE_RESPONSE_HEARTBEAT_MS = 5_000;
68
81
  export function armBudgetWatchdog(budgetMs, controller, deps) {
69
82
  const setTimeoutFn = deps?.setTimeoutFn ?? setTimeout;
70
83
  const clearTimeoutFn = deps?.clearTimeoutFn ?? clearTimeout;
@@ -119,6 +132,12 @@ export async function akmImprove(options = {}) {
119
132
  options = setup.options;
120
133
  const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, } = setup;
121
134
  let clearBudgetTimer = () => { };
135
+ let clearFirstResponseHeartbeat = () => { };
136
+ // #957: set by the usage sink's onRecord callback the moment any engine
137
+ // call terminates (success or error), including one issued by the prepass
138
+ // itself — makes arming the heartbeat below a no-op when the run is
139
+ // already known not to be silent.
140
+ let firstEngineResponseSeen = false;
122
141
  let initialGitPaths = new Set();
123
142
  const runJournal = createRunWriteJournal();
124
143
  const preEnsureCleanupWarnings = [];
@@ -175,7 +194,10 @@ export async function akmImprove(options = {}) {
175
194
  return buildLockSkippedResult(selectedStrategy.name, scope, options.runId);
176
195
  }
177
196
  improveLockOwnership = acquisition.ownership;
178
- disposeLlmUsageSink = installLlmUsagePersistence(() => eventsCtx);
197
+ disposeLlmUsageSink = installLlmUsagePersistence(() => eventsCtx, () => {
198
+ firstEngineResponseSeen = true;
199
+ clearFirstResponseHeartbeat();
200
+ });
179
201
  exitBackstop = releaseRunLock;
180
202
  process.on("exit", exitBackstop);
181
203
  initialGitPaths =
@@ -215,6 +237,7 @@ export async function akmImprove(options = {}) {
215
237
  // If the live prepass fails, emit its summary and clear the owning sink
216
238
  // before any run teardown. The disposer is idempotent with the main finalizer.
217
239
  disposeLlmUsageSink();
240
+ clearFirstResponseHeartbeat();
218
241
  clearBudgetTimer();
219
242
  if (exitBackstop) {
220
243
  process.removeListener("exit", exitBackstop);
@@ -233,6 +256,20 @@ export async function akmImprove(options = {}) {
233
256
  // are all in hand. See buildImproveRunContext for exactly which
234
257
  // already-resolved values back each field.
235
258
  const ctx = buildImproveRunContext(setup, eventsCtx);
259
+ // #957: arm the heartbeat here, immediately before the improve loop
260
+ // starts dispatching engine requests — not at run start, where its timer
261
+ // would measure the triage/index prepass instead of engine latency. A
262
+ // no-op when the prepass already produced a terminal LLM record (the
263
+ // onRecord callback above already saw it). Cleared the moment any call
264
+ // terminates (success or error) — never rearmed, so this prints at most
265
+ // once per run.
266
+ if (!firstEngineResponseSeen) {
267
+ const firstResponseTimer = setTimeout(() => {
268
+ warn("[improve] Still waiting for the first engine response...");
269
+ }, FIRST_ENGINE_RESPONSE_HEARTBEAT_MS);
270
+ firstResponseTimer.unref?.();
271
+ clearFirstResponseHeartbeat = () => clearTimeout(firstResponseTimer);
272
+ }
236
273
  const seq = await runImproveStageSequence({
237
274
  run: setup,
238
275
  strategyFilteredRefs,
@@ -292,6 +329,8 @@ export async function akmImprove(options = {}) {
292
329
  // #576: clear the per-run LLM usage sink BEFORE closing `eventsDb` below, so
293
330
  // no late sink invocation can write through a closed handle.
294
331
  disposeLlmUsageSink();
332
+ // #957: never leave the first-response heartbeat timer pending past the run.
333
+ clearFirstResponseHeartbeat();
295
334
  // O-1 (#364): Clear the budget abort timer so it does not keep the event
296
335
  // loop alive after the run completes.
297
336
  clearBudgetTimer();
@@ -345,8 +384,13 @@ function describeRunWrittenPaths(setup, writtenPaths) {
345
384
  }
346
385
  return [...described].sort();
347
386
  }
348
- /** Resolve a dry-run inspection source without adapting it into a write target. */
349
- function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
387
+ /**
388
+ * Resolve a dry-run inspection source without adapting it into a write target.
389
+ * Exported so `improve-cli.ts`'s `--show-prompt` (#952) can resolve the same
390
+ * read-only bundle a plain `--dry-run` would, without duplicating this
391
+ * selector/target/fallback precedence.
392
+ */
393
+ export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
350
394
  if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
351
395
  throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or use --target ${scopedRef.origin}.`);
352
396
  }