akm-cli 0.9.15-beta.3 → 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.3] - 2026-09-10
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`
@@ -279,6 +280,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
279
280
  it differs from the running CLI, pointing at `akm task sync` as the
280
281
  remedy. `unknown` when not probed, no task is installed, or the recorded
281
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.
282
296
 
283
297
  ### Changed
284
298
 
@@ -610,6 +624,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
610
624
  precedent (#948) for state.db; the original driver text survives as
611
625
  `cause`. `--skip-if-locked` is unaffected — it already skips gracefully
612
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).
613
639
  - **The fingerprint-rename canary embeds the exact text the stored vector was
614
640
  generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
615
641
  the entry's raw `search_text`, while the main embedding pass caps it to
@@ -621,6 +647,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
621
647
  purge and rebuild on a same-model rename. The canary now caps each sampled
622
648
  entry's search text the same way, through the same `capEmbeddingText`
623
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.
624
678
 
625
679
  ## [0.9.14] - 2026-09-04
626
680
 
@@ -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
@@ -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
  }
@@ -29,7 +29,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
29
29
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
30
30
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
- import { ConfigError } from "../../core/errors.js";
32
+ import { ConfigError, UsageError } from "../../core/errors.js";
33
33
  import { appendEvent, readEvents } from "../../core/events.js";
34
34
  import { lintLessonContent } from "../../core/lesson-lint.js";
35
35
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
@@ -1371,6 +1371,92 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1371
1371
  }
1372
1372
  return { assetContent, parsedRef };
1373
1373
  }
1374
+ /**
1375
+ * #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
1376
+ * E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
1377
+ * direct-LLM HTTP path never touches argv, so it can use the resolved
1378
+ * engine's own context window instead. The reserve for "the rest of the
1379
+ * prompt" is measured directly (not guessed): build the same prompt with
1380
+ * the content cap forced to zero and use its length as the overhead, so
1381
+ * feedback/standards/schema-hints/prior-draft size is accounted for
1382
+ * exactly, per this call. A reflect rewrite returns a body roughly the
1383
+ * size of the input, so the budget only spends HALF of the usable window
1384
+ * on input content and reserves the other half for the model's own
1385
+ * output — otherwise a full-context request leaves no room for a
1386
+ * response. Never drops below the flat floor.
1387
+ *
1388
+ * Shared by the real dispatch path ({@link runReflectRefineIterations}) and
1389
+ * `renderReflectPromptPreview`'s `--show-prompt` preview, so the preview
1390
+ * renders the exact prompt reflect would actually send for LLM runners
1391
+ * instead of always the flat-cap prompt.
1392
+ */
1393
+ function computeReflectContentBudgetChars(promptInput, runnerSpec) {
1394
+ return runnerIsLlm(runnerSpec) && promptInput.assetContent?.trim()
1395
+ ? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
1396
+ buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
1397
+ 2))
1398
+ : undefined;
1399
+ }
1400
+ /**
1401
+ * #952 — gather every read-only prompt-input source {@link buildReflectPromptInput}
1402
+ * folds into a `ReflectPromptInput`: recent feedback, schema/lint hints, related
1403
+ * lessons, previously-rejected proposals, and stash standards context.
1404
+ *
1405
+ * Shared by the real dispatch path (`akmReflect`'s step 4, via
1406
+ * {@link runReflectRefineIterations}) and `renderReflectPromptPreview`'s
1407
+ * `--show-prompt` preview, so both gather from exactly one definition instead
1408
+ * of two copies that can drift out of agreement.
1409
+ */
1410
+ async function gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx) {
1411
+ const feedback = readRecentFeedback(options.ref ? (options.itemRef ?? durableImproveRef(options.ref)) : undefined, options.eventsCtx);
1412
+ const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
1413
+ const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef) : [];
1414
+ // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
1415
+ // reproducing proposals that have already been reviewed and refused.
1416
+ const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
1417
+ // Standards "rulebook" for this target — stash convention/meta facts; empty
1418
+ // when none fire.
1419
+ const standardsContext = resolveStandardsContext(options.ref, stash);
1420
+ return { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext };
1421
+ }
1422
+ /**
1423
+ * #952 — assemble the `ReflectPromptInput` object literal reflect actually
1424
+ * sends, from gathered sources plus the per-call values (draft path, prior
1425
+ * draft). Shared by the real dispatch path ({@link runReflectRefineIterations})
1426
+ * and `renderReflectPromptPreview`'s `--show-prompt` preview — including
1427
+ * `avoidPatterns`, which the preview previously omitted even though a live
1428
+ * improve loop passes it (recent-error context, O-5 / #378).
1429
+ */
1430
+ function buildReflectPromptInput(args) {
1431
+ const { options, parsedRef, assetContent, sources, runnerSpec, draftFilePath, priorDraft } = args;
1432
+ const { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext } = sources;
1433
+ const outputMode = runnerIsLlm(runnerSpec)
1434
+ ? wantsJsonSchemaOutput(runnerSpec.connection)
1435
+ ? "json_schema"
1436
+ : "framed_markdown"
1437
+ : undefined;
1438
+ return {
1439
+ ...(options.ref ? { ref: options.ref } : {}),
1440
+ ...(parsedRef?.type ? { type: parsedRef.type } : {}),
1441
+ ...(parsedRef?.name ? { name: parsedRef.name } : {}),
1442
+ ...(assetContent !== undefined ? { assetContent } : {}),
1443
+ ...(feedback.length > 0 ? { feedback } : {}),
1444
+ ...(schemaHints.length > 0 ? { schemaHints } : {}),
1445
+ ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
1446
+ ...(options.task ? { task: options.task } : {}),
1447
+ ...(standardsContext.trim() ? { standardsContext } : {}),
1448
+ ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
1449
+ ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
1450
+ // R-1: inject prior draft as self-critique target on iterations > 0
1451
+ ...(priorDraft !== undefined ? { priorDraft } : {}),
1452
+ // Issue A (#reflect-pipeline file-write contract): when the runner can
1453
+ // touch the filesystem, instruct the agent to write the proposal body
1454
+ // to a tmp file instead of inlining it in JSON. Avoids parse failures
1455
+ // on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
1456
+ ...(draftFilePath ? { draftFilePath } : {}),
1457
+ ...(outputMode ? { outputMode } : {}),
1458
+ };
1459
+ }
1374
1460
  /**
1375
1461
  * Run the agent with the optional Self-Refine loop (R-1 / #372): up to
1376
1462
  * `maxRefineIters` invocations, each injecting the prior draft as self-critique
@@ -1379,17 +1465,12 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1379
1465
  * result + last draft path. Extracted verbatim from `akmReflect`.
1380
1466
  */
1381
1467
  async function runReflectRefineIterations(args) {
1382
- const { options, parsedRef, assetContent, feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices, } = args;
1468
+ const { options, parsedRef, assetContent, sources, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices } = args;
1383
1469
  const maxRefineIters = Math.max(1, options.maxRefineIters ?? 1);
1384
1470
  // Determine whether this dispatch can honour the file-write contract.
1385
1471
  // Agent CLI + OpenCode SDK runners both have filesystem access; the direct
1386
1472
  // LLM HTTP runner does NOT.
1387
1473
  const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1388
- const outputMode = runnerIsLlm(runnerSpec)
1389
- ? wantsJsonSchemaOutput(runnerSpec.connection)
1390
- ? "json_schema"
1391
- : "framed_markdown"
1392
- : undefined;
1393
1474
  // Initialized to a sentinel; always overwritten in the first loop iteration
1394
1475
  // (maxRefineIters is clamped to >= 1 above).
1395
1476
  let result = {};
@@ -1404,44 +1485,16 @@ async function runReflectRefineIterations(args) {
1404
1485
  draftPathsToCleanup.push(iterDraftPath);
1405
1486
  lastDraftPath = iterDraftPath;
1406
1487
  }
1407
- const promptInput = {
1408
- ...(options.ref ? { ref: options.ref } : {}),
1409
- ...(parsedRef?.type ? { type: parsedRef.type } : {}),
1410
- ...(parsedRef?.name ? { name: parsedRef.name } : {}),
1411
- ...(assetContent !== undefined ? { assetContent } : {}),
1412
- ...(feedback.length > 0 ? { feedback } : {}),
1413
- ...(schemaHints.length > 0 ? { schemaHints } : {}),
1414
- ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
1415
- ...(options.task ? { task: options.task } : {}),
1416
- ...(standardsContext.trim() ? { standardsContext } : {}),
1417
- ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
1418
- ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
1419
- // R-1: inject prior draft as self-critique target on iterations > 0
1420
- ...(priorDraft !== undefined ? { priorDraft } : {}),
1421
- // Issue A (#reflect-pipeline file-write contract): when the runner can
1422
- // touch the filesystem, instruct the agent to write the proposal body
1423
- // to a tmp file instead of inlining it in JSON. Avoids parse failures
1424
- // on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
1425
- ...(iterDraftPath ? { draftFilePath: iterDraftPath } : {}),
1426
- ...(outputMode ? { outputMode } : {}),
1427
- };
1428
- // #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
1429
- // E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
1430
- // direct-LLM HTTP path never touches argv, so it can use the resolved
1431
- // engine's own context window instead. The reserve for "the rest of the
1432
- // prompt" is measured directly (not guessed): build the same prompt with
1433
- // the content cap forced to zero and use its length as the overhead, so
1434
- // feedback/standards/schema-hints/prior-draft size is accounted for
1435
- // exactly, per this call. A reflect rewrite returns a body roughly the
1436
- // size of the input, so the budget only spends HALF of the usable window
1437
- // on input content and reserves the other half for the model's own
1438
- // output — otherwise a full-context request leaves no room for a
1439
- // response. Never drops below the flat floor.
1440
- const contentBudgetChars = runnerIsLlm(runnerSpec) && assetContent?.trim()
1441
- ? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
1442
- buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
1443
- 2))
1444
- : undefined;
1488
+ const promptInput = buildReflectPromptInput({
1489
+ options,
1490
+ parsedRef,
1491
+ assetContent,
1492
+ sources,
1493
+ runnerSpec,
1494
+ draftFilePath: iterDraftPath,
1495
+ priorDraft,
1496
+ });
1497
+ const contentBudgetChars = computeReflectContentBudgetChars(promptInput, runnerSpec);
1445
1498
  const { prompt } = buildReflectPrompt({
1446
1499
  ...promptInput,
1447
1500
  ...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
@@ -1459,10 +1512,10 @@ async function runReflectRefineIterations(args) {
1459
1512
  ...(options.signal ? { signal: options.signal } : {}),
1460
1513
  priorDraft,
1461
1514
  iteration: iter,
1462
- ...(outputMode === "json_schema"
1515
+ ...(promptInput.outputMode === "json_schema"
1463
1516
  ? { responseSchema: options.ref ? REFLECT_JSON_SCHEMA : REFLECT_UNSCOPED_JSON_SCHEMA }
1464
1517
  : {}),
1465
- outputMode: outputMode ?? "framed_markdown",
1518
+ outputMode: promptInput.outputMode ?? "framed_markdown",
1466
1519
  ...(options.ref ? { targetRef: options.ref } : {}),
1467
1520
  allowRepair: repairAttempts === 0,
1468
1521
  ...(options.chat ? { chat: options.chat } : {}),
@@ -1650,6 +1703,62 @@ function validateReflectPayloadRef(args) {
1650
1703
  return undefined;
1651
1704
  }
1652
1705
  }
1706
+ /**
1707
+ * #952 — render the composed reflect prompt for exactly one asset with no
1708
+ * engine dispatch. Reuses every read-only step `akmReflect` performs before
1709
+ * {@link buildReflectPrompt} (source resolution, runner resolution, feedback /
1710
+ * schema-hint / related-lesson / rejected-proposal gathering) and stops right
1711
+ * there: no dispatch lease is acquired, no request is sent, and — because the
1712
+ * `emitReflectFailed` callback passed to {@link resolveReflectSource} here is
1713
+ * a no-op — no `reflect_invoked`/`reflect_completed` event is appended either.
1714
+ *
1715
+ * `akm improve <ref> --show-prompt` (`improve-cli.ts`) is the CLI surface: a
1716
+ * field operator uses it to see the exact prompt reflect would send, in
1717
+ * seconds, without running a full improve cycle or needing a reachable
1718
+ * engine.
1719
+ */
1720
+ export async function renderReflectPromptPreview(options) {
1721
+ if (!options.ref) {
1722
+ throw new UsageError("renderReflectPromptPreview requires options.ref.", "INVALID_FLAG_VALUE");
1723
+ }
1724
+ const ref = options.ref;
1725
+ const stash = resolveRunStashDir(options.stashDir);
1726
+ const sourceResolved = await resolveReflectSource(options, stash, () => {
1727
+ // No event emitted: this is a read-only preview, not a real invocation.
1728
+ });
1729
+ if ("failure" in sourceResolved) {
1730
+ const { failure } = sourceResolved;
1731
+ throw new UsageError((!failure.ok && failure.error) || `Reflect cannot preview ref "${ref}".`, "INVALID_FLAG_VALUE");
1732
+ }
1733
+ const { assetContent, parsedRef } = sourceResolved;
1734
+ const { runnerSpec, engineName } = resolveReflectRunner(options);
1735
+ const ctx = buildReflectRunContext({ options, stash, config: options.config ?? loadConfig(), runnerSpec });
1736
+ const assetCtx = ctx.withFreshAssetMemo();
1737
+ const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
1738
+ const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1739
+ // Same tmp-path synthesis a real dispatch would use (Issue A) — never
1740
+ // written to, since this preview never runs the agent.
1741
+ const draftFilePath = canRunnerWriteFile ? synthesizeReflectDraftPath(ref) : undefined;
1742
+ const previewPromptInput = buildReflectPromptInput({
1743
+ options,
1744
+ parsedRef,
1745
+ assetContent,
1746
+ sources,
1747
+ runnerSpec,
1748
+ draftFilePath,
1749
+ priorDraft: undefined,
1750
+ });
1751
+ // #952 — mirror the real dispatch path's context-aware content budget (see
1752
+ // computeReflectContentBudgetChars) so the preview shows the exact prompt
1753
+ // reflect would send: an LLM engine with a large context window gets the
1754
+ // full asset with no truncation marker, not the flat 12 000-char cap.
1755
+ const contentBudgetChars = computeReflectContentBudgetChars(previewPromptInput, runnerSpec);
1756
+ const { prompt } = buildReflectPrompt({
1757
+ ...previewPromptInput,
1758
+ ...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
1759
+ });
1760
+ return { ref, prompt, engine: engineName, engineKind: runnerSpec.kind };
1761
+ }
1653
1762
  export async function akmReflect(options = {}) {
1654
1763
  const stash = resolveRunStashDir(options.stashDir);
1655
1764
  // Build lazy event emitters. The invocation row is committed only after the
@@ -1695,17 +1804,7 @@ export async function akmReflect(options = {}) {
1695
1804
  // 4. Build the shared prompt inputs — feedback, hints, lessons, rejected
1696
1805
  // proposals. These are stable across refinement iterations; only the
1697
1806
  // `priorDraft` field changes per-iteration (R-1 / #372).
1698
- const feedback = readRecentFeedback(options.ref ? (options.itemRef ?? durableImproveRef(options.ref)) : undefined, options.eventsCtx);
1699
- const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
1700
- const relatedLessons = options.ref && parsedRef
1701
- ? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef)
1702
- : [];
1703
- // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
1704
- // reproducing proposals that have already been reviewed and refused.
1705
- const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
1706
- // Standards "rulebook" for this target — stash convention/meta facts; empty
1707
- // when none fire.
1708
- const standardsContext = resolveStandardsContext(options.ref, stash);
1807
+ const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
1709
1808
  // 5. Spawn the agent — with the optional Self-Refine loop (R-1 / #372),
1710
1809
  // extracted to {@link runReflectRefineIterations}.
1711
1810
  const agentEnv = options.eventSource === "improve" ? { AKM_EVENT_SOURCE: "improve" } : {};
@@ -1725,11 +1824,7 @@ export async function akmReflect(options = {}) {
1725
1824
  options,
1726
1825
  parsedRef,
1727
1826
  assetContent,
1728
- feedback,
1729
- schemaHints,
1730
- relatedLessons,
1731
- rejectedProposals,
1732
- standardsContext,
1827
+ sources,
1733
1828
  runnerSpec,
1734
1829
  lease: generationLease,
1735
1830
  agentEnv,
@@ -1808,7 +1903,7 @@ export async function akmReflect(options = {}) {
1808
1903
  qualityGateSkippedNoJudge,
1809
1904
  qualityJudgeRunner,
1810
1905
  qualityJudgeLease,
1811
- feedback,
1906
+ feedback: sources.feedback,
1812
1907
  stash,
1813
1908
  emitReflectFailed,
1814
1909
  onNotices: collectExecutionNotices,
@@ -83,6 +83,7 @@ const TRANSIENT_HINTS = {
83
83
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
84
84
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
85
85
  INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
86
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
86
87
  };
87
88
  /** Default hint for each NotFoundError code. */
88
89
  const NOT_FOUND_HINTS = {
@@ -6,7 +6,8 @@ import { randomUUID } from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { sleepSync } from "../runtime.js";
9
- import { ConfigError } from "./errors.js";
9
+ import { backoffDelay } from "./common.js";
10
+ import { ConfigError, TransientError } from "./errors.js";
10
11
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
11
12
  import { getMaintenanceBarrierPath } from "./paths.js";
12
13
  const heldBarrierContext = new AsyncLocalStorage();
@@ -24,6 +25,26 @@ const heldBarrierContext = new AsyncLocalStorage();
24
25
  * (`commands/improve/extract.ts`).
25
26
  */
26
27
  const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
28
+ /**
29
+ * The barrier normally holds for one lock-file write — sub-millisecond on
30
+ * any real filesystem. Two akm processes racing to register a lock in the
31
+ * very same instant (e.g. two `akm index` runs a scheduler launched back to
32
+ * back) can still collide on it; retrying briefly resolves that ordinary
33
+ * case instead of failing a legitimate concurrent invocation outright
34
+ * (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
35
+ * still surfaces the busy error promptly rather than making a losing
36
+ * process hang — comfortably above the barrier's normal hold time, well
37
+ * below a length that would make this feel like the blocking lock #872
38
+ * removed. Never applies to the rebuild lock itself, which stays
39
+ * non-blocking (#872).
40
+ */
41
+ const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
42
+ let busyRetryBoundMsForTests;
43
+ /** Test-only override for {@link MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS}, so a unit test can exercise the
44
+ * exhausted-retry throw without a real ~1.5s wait. Restored via tests/_helpers/seams.ts's resetAllSeams(). */
45
+ export function _setMaintenanceBarrierBusyRetryBoundMsForTests(ms) {
46
+ busyRetryBoundMsForTests = ms;
47
+ }
27
48
  /**
28
49
  * Serialize the short critical section that creates each long-lived AKM lock,
29
50
  * lease, or state activity. The operation keeps its own ownership record; this
@@ -44,11 +65,19 @@ export function tryAcquireMaintenanceBarrier() {
44
65
  return undefined;
45
66
  }
46
67
  export function acquireMaintenanceBarrier() {
47
- const release = tryAcquireMaintenanceBarrier();
48
- if (release)
49
- return release;
50
- throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes. ` +
51
- `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "INVALID_CONFIG_FILE");
68
+ const boundMs = busyRetryBoundMsForTests ?? MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS;
69
+ const deadline = Date.now() + boundMs;
70
+ for (let attempt = 0;; attempt += 1) {
71
+ const release = tryAcquireMaintenanceBarrier();
72
+ if (release)
73
+ return release;
74
+ const remainingMs = deadline - Date.now();
75
+ if (remainingMs <= 0)
76
+ break;
77
+ sleepSync(Math.min(backoffDelay(attempt), remainingMs));
78
+ }
79
+ throw new TransientError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry shortly. ` +
80
+ `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "MAINTENANCE_BARRIER_BUSY");
52
81
  }
53
82
  export function withMaintenanceStartBarrier(run) {
54
83
  if (heldBarrierContext.getStore()?.active)
@@ -472,3 +472,21 @@ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
472
472
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
473
473
  }
474
474
  }
475
+ /**
476
+ * Endpoint-keyed probe memoization (#957): every caller that probes several
477
+ * engine connections in one pass (`akm health`'s engine checks, `akm
478
+ * improve --require-engines`) shares one in-flight probe per distinct
479
+ * endpoint (trailing slashes normalized) instead of firing a duplicate probe
480
+ * when two engines point at the same server. `cache` must be scoped to one
481
+ * invocation and never shared across calls — a stale "reachable" surviving
482
+ * past the run that produced it is the failure mode this exists to avoid.
483
+ */
484
+ export function probeEndpointOnce(connection, cache, probe) {
485
+ const key = connection.endpoint.replace(/\/+$/, "");
486
+ let pending = cache.get(key);
487
+ if (!pending) {
488
+ pending = probe(connection);
489
+ cache.set(key, pending);
490
+ }
491
+ return pending;
492
+ }
@@ -67,12 +67,18 @@ function toEventMetadata(record) {
67
67
  * opened for its other events. A getter is resolved for every append so a
68
68
  * caller can replace its context binding without replacing this owning sink.
69
69
  * When omitted, `appendEvent` falls back to its default open-insert-close path.
70
+ *
71
+ * `onRecord`, when supplied, runs synchronously for every terminal record
72
+ * BEFORE persistence — improve's first-engine-response heartbeat (#957) uses
73
+ * it to know the run is no longer silent, without this module taking on any
74
+ * dependency of its own on improve's lifecycle.
70
75
  */
71
- export function installLlmUsagePersistence(ctx) {
76
+ export function installLlmUsagePersistence(ctx, onRecord) {
72
77
  let expectedTerminalRecords = 0;
73
78
  let disposed = false;
74
79
  setLlmUsageSink((record) => {
75
80
  expectedTerminalRecords += 1;
81
+ onRecord?.(record);
76
82
  appendEvent({ eventType: LLM_USAGE_EVENT, metadata: toEventMetadata(record) }, typeof ctx === "function" ? ctx() : ctx);
77
83
  });
78
84
  return () => {
@@ -7071,7 +7071,8 @@ var init_errors = __esm(() => {
7071
7071
  TRANSIENT_HINTS = {
7072
7072
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
7073
7073
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
7074
- INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs."
7074
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
7075
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs."
7075
7076
  };
7076
7077
  NOT_FOUND_HINTS = {
7077
7078
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -77923,10 +77924,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
77923
77924
  import { randomUUID as randomUUID2 } from "node:crypto";
77924
77925
  import fs7 from "node:fs";
77925
77926
  import path12 from "node:path";
77927
+ init_common();
77926
77928
  init_errors();
77927
77929
  init_paths();
77928
77930
  var heldBarrierContext = new AsyncLocalStorage;
77929
77931
  var MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
77932
+ var MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1500;
77933
+ var busyRetryBoundMsForTests;
77930
77934
  function tryAcquireMaintenanceBarrier() {
77931
77935
  const lockPath = getMaintenanceBarrierPath();
77932
77936
  fs7.mkdirSync(path12.dirname(lockPath), { recursive: true });
@@ -77942,10 +77946,18 @@ function tryAcquireMaintenanceBarrier() {
77942
77946
  return;
77943
77947
  }
77944
77948
  function acquireMaintenanceBarrier() {
77945
- const release = tryAcquireMaintenanceBarrier();
77946
- if (release)
77947
- return release;
77948
- throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes. ` + `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60000} minute(s) is reclaimed automatically on the next attempt.`, "INVALID_CONFIG_FILE");
77949
+ const boundMs = busyRetryBoundMsForTests ?? MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS;
77950
+ const deadline = Date.now() + boundMs;
77951
+ for (let attempt = 0;; attempt += 1) {
77952
+ const release = tryAcquireMaintenanceBarrier();
77953
+ if (release)
77954
+ return release;
77955
+ const remainingMs = deadline - Date.now();
77956
+ if (remainingMs <= 0)
77957
+ break;
77958
+ sleepSync(Math.min(backoffDelay(attempt), remainingMs));
77959
+ }
77960
+ throw new TransientError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry shortly. ` + `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60000} minute(s) is reclaimed automatically on the next attempt.`, "MAINTENANCE_BARRIER_BUSY");
77949
77961
  }
77950
77962
  function withMaintenanceStartBarrier(run) {
77951
77963
  if (heldBarrierContext.getStore()?.active)
@@ -7070,7 +7070,8 @@ var init_errors = __esm(() => {
7070
7070
  TRANSIENT_HINTS = {
7071
7071
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
7072
7072
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
7073
- INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs."
7073
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
7074
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs."
7074
7075
  };
7075
7076
  NOT_FOUND_HINTS = {
7076
7077
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -77251,10 +77252,13 @@ import { AsyncLocalStorage } from "async_hooks";
77251
77252
  import { randomUUID as randomUUID2 } from "crypto";
77252
77253
  import fs7 from "fs";
77253
77254
  import path12 from "path";
77255
+ init_common();
77254
77256
  init_errors();
77255
77257
  init_paths();
77256
77258
  var heldBarrierContext = new AsyncLocalStorage;
77257
77259
  var MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
77260
+ var MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1500;
77261
+ var busyRetryBoundMsForTests;
77258
77262
  function tryAcquireMaintenanceBarrier() {
77259
77263
  const lockPath = getMaintenanceBarrierPath();
77260
77264
  fs7.mkdirSync(path12.dirname(lockPath), { recursive: true });
@@ -77270,10 +77274,18 @@ function tryAcquireMaintenanceBarrier() {
77270
77274
  return;
77271
77275
  }
77272
77276
  function acquireMaintenanceBarrier() {
77273
- const release = tryAcquireMaintenanceBarrier();
77274
- if (release)
77275
- return release;
77276
- throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes. ` + `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60000} minute(s) is reclaimed automatically on the next attempt.`, "INVALID_CONFIG_FILE");
77277
+ const boundMs = busyRetryBoundMsForTests ?? MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS;
77278
+ const deadline = Date.now() + boundMs;
77279
+ for (let attempt = 0;; attempt += 1) {
77280
+ const release = tryAcquireMaintenanceBarrier();
77281
+ if (release)
77282
+ return release;
77283
+ const remainingMs = deadline - Date.now();
77284
+ if (remainingMs <= 0)
77285
+ break;
77286
+ sleepSync(Math.min(backoffDelay(attempt), remainingMs));
77287
+ }
77288
+ throw new TransientError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry shortly. ` + `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60000} minute(s) is reclaimed automatically on the next attempt.`, "MAINTENANCE_BARRIER_BUSY");
77277
77289
  }
77278
77290
  function withMaintenanceStartBarrier(run) {
77279
77291
  if (heldBarrierContext.getStore()?.active)
@@ -8,6 +8,20 @@ now, retry shortly." If a script or scheduler wrapper special-cases exit 2 to
8
8
  detect a held lease, switch it to exit 75, or read the JSON envelope's `code`
9
9
  field instead.
10
10
 
11
+ A concurrent `akm index` now also exits 75 instead of exit 78 or exit 70. A
12
+ 2026-09-10 field report found a second `akm index` (no `--skip-if-locked`)
13
+ colliding on the short internal barrier that registers the opt-in rebuild
14
+ lock could fail with `{"code":"INVALID_CONFIG_FILE"}` at exit 78 — a
15
+ config-error exit that told a supervisor to stop retrying ordinary
16
+ contention between two legitimate runs. That barrier now retries briefly
17
+ before giving up, and a busy barrier is reclassified as `TransientError`
18
+ code `MAINTENANCE_BARRIER_BUSY` (exit 75) instead. Contention on index.db
19
+ itself was already reclassified from a raw `database is locked` error (exit
20
+ 70) to `TransientError` code `INDEX_DB_CONTENDED` (exit 75). Either way, the
21
+ fix for a scheduled or opportunistic index run is the same:
22
+ `akm index --skip-if-locked` steps aside (exit 0) instead of contending at
23
+ all.
24
+
11
25
  The six shipped scheduled `improve` task templates now run with
12
26
  `--require-engines`, which aborts (exit 78) before any index work when a
13
27
  process's engine or credential cannot be resolved in the task's own
@@ -17,6 +31,23 @@ already-materialized task files are not rewritten. To get the same protection
17
31
  on an existing scheduled task, add `--require-engines` to its `run:` command
18
32
  yourself, then run `akm task sync`.
19
33
 
34
+ `--require-engines` now also runs a bounded reachability probe against each
35
+ distinct engine endpoint (the same probe `akm health` already uses), not
36
+ only a config/credential check. A field re-test found the flag let a run
37
+ through to a fully dead endpoint, which then sat silent for minutes making
38
+ no progress and no exit — the flag's exit-78 abort now catches that case up
39
+ front, naming the unreachable engine and endpoint, before any index work.
40
+ If a scheduled `--require-engines` run starts failing at exit 78 after
41
+ upgrading, check that the engine's endpoint actually answers — this is the
42
+ flag doing its documented job on a condition it previously missed, not a
43
+ new failure mode. Separately, `--timeout-ms` and an engine's own configured
44
+ timeout already aborted an in-flight request correctly, and SIGTERM/SIGINT
45
+ already ended a run within its documented grace period — both confirmed,
46
+ not changed, by this investigation. A live run now also prints one
47
+ default-level line if it waits more than a few seconds on its first engine
48
+ response, so a slow-but-alive run and a dead one are never indistinguishable
49
+ from silence alone.
50
+
20
51
  `akm health --no-probe` now also skips the `cli-version` update check (a GitHub
21
52
  release lookup), alongside the engine-reachability checks it already skipped.
22
53
  An air-gapped or offline host's existing `--no-probe` habit now suppresses both
@@ -114,6 +145,40 @@ instead of ever failing a whole batch over one oversized document.
114
145
  set `contextLength` specifically to control request batching (not your
115
146
  Ollama server's context window), set `embedding.maxTokens` instead.
116
147
 
148
+ **Which token knob fixed the original 8k-context overflow.** A 0.9.15-beta
149
+ field report described documents estimated under the request budget that
150
+ still tokenized to 8.5k-12.4k real tokens against an 8192-token endpoint,
151
+ because the 4-chars-per-token estimator undercounts dense technical text.
152
+ `maxInputTokens`, `maxTokens`, and `contextLength` are easy to confuse, and
153
+ only one of them makes that overflow structurally unreachable:
154
+
155
+ - `embedding.maxInputTokens` (default `512`) is the per-DOCUMENT cap. It
156
+ truncates a document's embedded text to its head before the document is
157
+ ever counted toward a request, so no single document can contribute more
158
+ than 512 estimated tokens. This is the fix: it makes the original
159
+ single-document overflow structurally unreachable, independent of the
160
+ other two knobs.
161
+ - `embedding.maxTokens` (default `6000`) is the per-REQUEST budget — how
162
+ many already-capped documents fit in one HTTP request — plus a same-run
163
+ adaptive shrink on the first context-size rejection. It reduces how often
164
+ a request lands near an endpoint's real limit, but a request-level budget
165
+ alone cannot stop one oversized document from overflowing a request.
166
+ - `embedding.contextLength` sets Ollama's `num_ctx` only. It no longer feeds
167
+ the request token budget the way it used to (the two fields used to share
168
+ this one value), and it has no effect at all against a non-Ollama
169
+ endpoint.
170
+
171
+ The field's exact 0.9.15-beta config — `contextLength: 8192` and
172
+ `maxTokens: 8000` — produces no 400s on 0.9.15. `maxTokens` now defaults
173
+ lower anyway (6000), but that is not why the overflow stopped: every
174
+ document is truncated to `maxInputTokens` (512 tokens) before it is counted
175
+ toward any request, so the 8.5k-12.4k-token documents that used to overflow
176
+ an 8192-token endpoint can no longer reach the request budget in the first
177
+ place. Set `embedding.maxInputTokens` higher only if you need documents
178
+ longer than ~2000 characters embedded in full — for a corpus with such
179
+ documents, size `embedding.maxTokens` to still fit the worst case, or the
180
+ overflow risk returns.
181
+
117
182
  `akm index --full` and an index-generation bump no longer re-embed
118
183
  unchanged content: vectors about to be discarded are salvaged and handed
119
184
  back to unchanged entries at the start of the next embedding pass instead
@@ -285,6 +285,13 @@ long enough to exhaust the driver's retry window, the run now fails with
285
285
  exit 75 (`TransientError`, code `INDEX_DB_CONTENDED`) instead of the raw
286
286
  driver error at exit 70 — the same retry-shortly contract as
287
287
  `STATE_DB_CONTENDED`, so a scheduler can branch on it instead of alerting.
288
+ The rebuild lock itself is registered through a brief internal barrier
289
+ (`getMaintenanceBarrierPath()`) shared with every other akm lock/lease; two
290
+ `akm index` runs launched close enough together to collide on that
291
+ registration step retry briefly and then, if it is still busy, also exit 75
292
+ (code `MAINTENANCE_BARRIER_BUSY`) rather than the config-error exit 78 a
293
+ 2026-09-10 field report found — a busy registration barrier is ordinary
294
+ contention between two legitimate runs, never a broken config file.
288
295
  `--skip-if-locked` changes that only for the invocation that passes it: if
289
296
  the lock is already held by a live process, it skips gracefully (exit 0,
290
297
  `{ ok: true, skipped: { reason: "lock-held", pid, launcherPid, startedAt } }`
@@ -2336,6 +2343,7 @@ akm improve --require-engines # for scheduled runs: abort (exit 78) ins
2336
2343
  akm improve --no-sync # skip the end-of-run git commit entirely (default: on for git-backed bundles)
2337
2344
  akm improve --sync --no-push # commit only, skip the push after it
2338
2345
  akm improve --plan --strategy thorough # preview thorough's resolved engine/model routing; nothing is dispatched
2346
+ akm improve lessons/my-lesson --show-prompt --format text # print the composed reflect prompt for one asset, unwrapped; no lock/index/engine call
2339
2347
  akm improve report # LLM usage/routing report for the most recent real run
2340
2348
  akm improve report --run <id> # ...for one specific improve_runs id
2341
2349
  akm improve report --since 7d # ...aggregated over every real run started in the last 7 days
@@ -2355,7 +2363,8 @@ akm improve report --since 7d # ...aggregated over every real run start
2355
2363
  | `--strategy <name>` | Override the active improve strategy (a built-in or entry under `improve.strategies`) |
2356
2364
  | `--json-to-stdout` | Also emit the full persisted JSON result on stdout for a live run. Without this flag, stdout stays empty. Dry-runs always emit their result and are never persisted. |
2357
2365
  | `--skip-if-locked` | 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. |
2358
- | `--require-engines` | Abort (exit 78, before any indexing, lock, or log side effect) 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: it skips the affected processes and reports them in the result's `skippedProcesses`. Recommended alongside `--skip-if-locked` for scheduled runs, since the operator's own shell can pass config validation while a scheduler's stripped-down environment (see #953) cannot. |
2366
+ | `--require-engines` | Abort (exit 78, before any indexing, lock, or log side effect) 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`'s `default-llm-engine`/`configured-engines` checks run, once per distinct endpoint. Without this flag, improve degrades gracefully: it skips the affected processes and reports them in the result's `skippedProcesses`. Recommended alongside `--skip-if-locked` for scheduled runs, since the operator's own shell can pass config validation while a scheduler's stripped-down environment (see #953) cannot. |
2367
+ | `--show-prompt` | Print the composed reflect prompt (#952) for one asset and exit — before any lock, index write, or engine dispatch. Requires a fully-qualified asset ref as the scope (`akm improve lessons/my-lesson --show-prompt`); rejected with a type or whole-bundle scope. The default output format is JSON, which carries the prompt as a `prompt` field (escaped into one line) alongside the resolved `engine`/`engineKind`; pass `--format text` to print the prompt itself, unwrapped and readable by eye. |
2359
2368
  | `--sync` / `--no-sync` | Commit (and optionally push) the git-backed primary bundle when the run finishes. Default: on for git-backed bundles (per profile config). |
2360
2369
  | `--push` / `--no-push` | Push after the end-of-run sync commit when writable with a remote configured. `--no-push` commits only, skipping the push. Default: per profile config (`true`). `sync.push` stays outside the autonomy gate — this is a per-run opt-out, not a default change. |
2361
2370
 
@@ -2411,6 +2420,18 @@ on an unavailable credential either — even a strategy left with every process
2411
2420
  disabled this way still returns its plan, with the affected processes in
2412
2421
  `skippedProcesses`.
2413
2422
 
2423
+ `--timeout-ms` is a run-wide wall-clock budget: when it expires, the run
2424
+ cooperatively aborts any in-flight engine request (the same `AbortSignal`
2425
+ every LLM call already honors) instead of waiting out the engine's own,
2426
+ much longer, per-call timeout — the run then finishes and reports normally
2427
+ rather than hanging past its budget. SIGTERM/SIGINT/SIGHUP end a live run
2428
+ the same way, within a short bounded grace period, and the process exits
2429
+ with a stable per-signal code (`143`/`130`/`129`) rather than needing a
2430
+ `kill -9`. If a live run has waited more than a few seconds without any
2431
+ engine response at all, one default-level line ("Still waiting for the
2432
+ first engine response...") is printed so a scheduled run's log is never
2433
+ silently empty while an engine is slow or dead.
2434
+
2414
2435
  For dry runs, `plannedRefs` is the effective post-limit work set, not every
2415
2436
  ref in the requested scope. The `plan` object preserves both views: raw scope
2416
2437
  size and per-gate removals, configured and effective caps, final ranked refs
@@ -2452,6 +2473,17 @@ no model or per-process notices. Neither `--dry-run` nor `--plan` probes
2452
2473
  engine reachability over the network — pair with `akm health --probe` (or the
2453
2474
  default probe-on behavior) to check whether a named engine actually answers.
2454
2475
 
2476
+ `--show-prompt` (#952) is the cheapest way to exercise reflect alone: it
2477
+ builds the exact prompt reflect would send for one asset — the same source
2478
+ resolution, runner selection, feedback/schema-hint/related-lesson/rejected-
2479
+ proposal gathering `akm improve`'s live reflect step uses — and prints it
2480
+ without acquiring a dispatch lease, so it never calls an engine. Add
2481
+ `--format text` (the default JSON/yaml envelope escapes the prompt into one
2482
+ line, which defeats a by-eye read) to confirm by eye that recent feedback is
2483
+ framed as an unverified report to investigate (never a fact to insert
2484
+ verbatim) and that the response contract tells the model never to emit the
2485
+ truncation marker or any content from outside the shown asset.
2486
+
2455
2487
  When reinforced facts need promotion, `knowledge` is the higher-authority
2456
2488
  destination than `memory`. The deterministic search ranking also prefers
2457
2489
  `knowledge` over `memory` hits, including inferred `.derived` memories, when
@@ -411,6 +411,40 @@ apply when unset), for a remote endpoint (`src/llm/embedders/remote.ts`):
411
411
  | `embedding.timeoutMs` | `120000` (120s) | Per-request wall timeout — see below. |
412
412
  | `embedding.concurrency` | `1` loopback / `2` remote | In-flight request window — see below. |
413
413
 
414
+ **Which knob fixed the field's 8k-context overflow, worked examples.** A
415
+ 0.9.15-beta field report described documents estimated under the request
416
+ budget that still tokenized to 8.5k-12.4k real tokens against an
417
+ 8192-token endpoint, because the 4-chars-per-token estimator undercounts
418
+ dense technical text. Three knobs changed shape between beta and this
419
+ release; only one of them makes that overflow structurally unreachable:
420
+
421
+ - `embedding.maxInputTokens: 512` — per-DOCUMENT cap, applied before
422
+ batching. Example: a 6,000-character API reference page is truncated to
423
+ its first ~2,000 characters (512 estimated tokens) before it is ever
424
+ counted toward a request. This is the fix for the original overflow: no
425
+ single document can contribute more than 512 estimated tokens to a
426
+ request, no matter how `maxTokens` or `contextLength` are set.
427
+ - `embedding.maxTokens: 6000` — per-REQUEST budget: how many already-capped
428
+ documents' estimated tokens fit in one HTTP request. Example: with the
429
+ default 512-token document cap, a request packs about 11 documents before
430
+ this budget is reached and the request is sent; if the run's first
431
+ request is still rejected for exceeding the endpoint's real context
432
+ window, akm shrinks this budget to three quarters of its value (floored
433
+ at twice `maxInputTokens`) for every later request in the same run. A
434
+ request-level budget alone cannot stop one oversized document from
435
+ overflowing a request — only the per-document cap above does that.
436
+ - `embedding.contextLength: 8192` — Ollama's `num_ctx` only, forwarded
437
+ verbatim on a native `/api/embed` request. It has no effect on request or
438
+ document sizing, and no effect at all against a non-Ollama endpoint — see
439
+ below for why that used not to be true.
440
+
441
+ A field config of `contextLength: 8192` + `maxTokens: 8000` (the exact
442
+ 0.9.15-beta values from the original report) produces no 400s on 0.9.15:
443
+ `maxInputTokens` (512, new this release) caps every document before it is
444
+ counted, so the original 8.5k-12.4k-token documents that overflowed the
445
+ 8192-token endpoint can never reach the request budget in the first place —
446
+ independent of whatever `maxTokens` or `contextLength` are set to.
447
+
414
448
  `embedding.timeoutMs` (positive integer, default `120000` — 120s) is the
415
449
  budget for a request at the FULL token budget (`embedding.maxTokens`); a
416
450
  local model server on a large, token-budget-bounded batch legitimately takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.15-beta.3",
3
+ "version": "0.9.15-beta.4",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [