akm-cli 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -111,7 +111,10 @@ export const configCommand = defineGroupCommand({
111
111
  },
112
112
  }),
113
113
  set: defineJsonCommand({
114
- meta: { name: "set", description: "Set a configuration value by key" },
114
+ meta: {
115
+ name: "set",
116
+ description: "Set a configuration value by key; prints the resulting config with ok: true",
117
+ },
115
118
  args: {
116
119
  key: {
117
120
  type: "positional",
@@ -124,7 +127,7 @@ export const configCommand = defineGroupCommand({
124
127
  // writes don't pollute their host's output stream.
125
128
  silent: {
126
129
  type: "boolean",
127
- description: "Suppress the post-write config dump on stdout. Use from hooks and CI scripts; the write still happens and errors still print.",
130
+ description: "Suppress the post-write config dump on stdout entirely (prints nothing; exit code is the status). Use from hooks and CI scripts; the write still happens and errors still print.",
128
131
  default: false,
129
132
  },
130
133
  },
@@ -136,12 +139,15 @@ export const configCommand = defineGroupCommand({
136
139
  },
137
140
  }),
138
141
  unset: defineJsonCommand({
139
- meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/engine section" },
142
+ meta: {
143
+ name: "unset",
144
+ description: "Unset an optional configuration key or whole embedding/engine section; prints the resulting config with ok: true",
145
+ },
140
146
  args: {
141
147
  key: { type: "positional", required: true, description: "Config key to unset" },
142
148
  silent: {
143
149
  type: "boolean",
144
- description: "Suppress the post-write config dump on stdout.",
150
+ description: "Suppress the post-write config dump on stdout entirely (prints nothing; exit code is the status).",
145
151
  default: false,
146
152
  },
147
153
  },
@@ -84,13 +84,20 @@ export function resolveEnvBinding(target, options = {}) {
84
84
  const dangerous = keys.filter(isDangerousEnvKey);
85
85
  if (dangerous.length > 0) {
86
86
  const detail = `Env "${envRef}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
87
- const decision = decideDangerousEnvInjection({ dangerousKeys: dangerous, thirdParty: Boolean(source.registryId) });
87
+ const decision = decideDangerousEnvInjection({
88
+ dangerousKeys: dangerous,
89
+ thirdParty: Boolean(source.registryId),
90
+ allowInsecure: options.allowInsecure,
91
+ });
88
92
  if (decision === "block") {
89
93
  throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
90
- ` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
94
+ ` Review the file, then copy the values into a first-party env if you trust them, ` +
95
+ `or pass --allow-insecure once you have.`, "INVALID_FLAG_VALUE");
91
96
  }
92
97
  if (decision === "warn") {
93
- warn(`${detail} Injecting anyway (first-party stash).`);
98
+ warn(options.allowInsecure && source.registryId
99
+ ? `${detail} Injecting anyway (--allow-insecure).`
100
+ : `${detail} Injecting anyway (first-party stash).`);
94
101
  }
95
102
  }
96
103
  // Audit trail: keys only, never values.
@@ -255,6 +255,7 @@ async function runEnvInjected(target, opts) {
255
255
  const { values: envValues } = resolveEnvBinding(target, {
256
256
  only: opts.only,
257
257
  except: opts.except,
258
+ allowInsecure: opts.allowInsecure,
258
259
  });
259
260
  const mergedEnv = buildChildEnv(process.env, {
260
261
  clean: opts.clean === true,
@@ -323,6 +324,11 @@ const envRunCommand = defineJsonCommand({
323
324
  type: "string",
324
325
  description: "When used with --clean, also inherit these parent env vars (comma-separated). Ignored without --clean.",
325
326
  },
327
+ "allow-insecure": {
328
+ type: "boolean",
329
+ description: "Allow injecting a process-hijacking variable (e.g. LD_PRELOAD, GIT_SSH_COMMAND) from a third-party stash, which otherwise blocks. Use only after explicitly reviewing the env file.",
330
+ default: false,
331
+ },
326
332
  },
327
333
  async run({ args }) {
328
334
  await runEnvInjected(args.target, {
@@ -330,6 +336,7 @@ const envRunCommand = defineJsonCommand({
330
336
  except: parseKeyListFlag(args.except),
331
337
  clean: args.clean === true,
332
338
  inherit: parseKeyListFlag(args.inherit) ?? [],
339
+ allowInsecure: args["allow-insecure"] === true,
333
340
  });
334
341
  },
335
342
  });
@@ -32,11 +32,13 @@ import path from "node:path";
32
32
  import { getParsedInvocation } from "../../cli/invocation.js";
33
33
  import { getStringArg } from "../../cli/parse-args.js";
34
34
  import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js";
35
+ import { decideDangerousEnvInjection } from "../../core/activation-policy.js";
35
36
  import { deriveCanonicalAssetName } from "../../core/asset/asset-placement.js";
36
37
  import { loadConfig } from "../../core/config/config.js";
37
38
  import { makeSecretRef, resolveSecretPath, resolveSecretWriteTarget, withEnvSecretWrite, } from "../../core/env-secret-ref.js";
38
39
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
39
40
  import { appendEvent } from "../../core/events.js";
41
+ import { warn } from "../../core/warn.js";
40
42
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
41
43
  import { readStdin } from "../../runtime.js";
42
44
  import { buildChildEnv } from "./child-env.js";
@@ -176,10 +178,6 @@ const secretRunCommand = defineJsonCommand({
176
178
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
177
179
  throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
178
180
  }
179
- const { isDangerousEnvKey } = await import("../lint/env-key-rules.js");
180
- if (isDangerousEnvKey(varName)) {
181
- throw new UsageError(`Refusing to inject a secret into "${varName}": it is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`, "INVALID_FLAG_VALUE");
182
- }
183
181
  const command = getParsedInvocation().passthroughArgs();
184
182
  if (command.length === 0) {
185
183
  throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
@@ -188,6 +186,19 @@ const secretRunCommand = defineJsonCommand({
188
186
  if (!fs.existsSync(absPath)) {
189
187
  throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
190
188
  }
189
+ const { isDangerousEnvKey } = await import("../lint/env-key-rules.js");
190
+ if (isDangerousEnvKey(varName)) {
191
+ const detail = `"${varName}" is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`;
192
+ const decision = decideDangerousEnvInjection({
193
+ dangerousKeys: [varName],
194
+ thirdParty: Boolean(source.registryId),
195
+ });
196
+ if (decision === "block") {
197
+ throw new UsageError(`Refusing to inject a secret from a third-party stash into ${detail}\n` +
198
+ " Review the secret, then copy it into a first-party secret if you trust it.", "INVALID_FLAG_VALUE");
199
+ }
200
+ warn(`Injecting a secret into ${detail} Injecting anyway (first-party stash).`);
201
+ }
191
202
  const { readValue } = await import("./secret.js");
192
203
  const mergedEnv = buildChildEnv(process.env, {
193
204
  clean: args.clean === true,
@@ -4,17 +4,56 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { loadConfig } from "../../core/config/config.js";
6
6
  import { ConfigError } from "../../core/errors.js";
7
+ import { EXTRACT_INFRASTRUCTURE_SKIP_REASONS } from "../../core/improve-types.js";
7
8
  import { listPendingStateMigrations } from "../../core/state-db.js";
8
9
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
9
- import { resolveEngine } from "../../integrations/agent/engine-resolution.js";
10
+ import { lookupApiKeyFileValue, resolveEngine } from "../../integrations/agent/engine-resolution.js";
10
11
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
11
12
  import { loadModelMap, mergeModelMapLayers, parseModelMapLayer, readInstalledModelMapText, resolveModelMapAlias, userModelMapPath, } from "../../integrations/agent/model-map.js";
12
13
  import { resolveImprovePlan } from "../improve/improve-strategies.js";
13
14
  import { ACTIVE_RUN_WARN_MS, TASK_FAIL_RATE_WARN } from "./types.js";
14
- function credentialAvailable(credential, env) {
15
- return !credential?.required || credential.names.some((name) => Boolean(env[name]?.trim()));
15
+ /** Probe one connection's reachability, once per endpoint; `undefined` when no probe seam is supplied. */
16
+ function probeConnectionReachable(connection, deps, cache) {
17
+ if (!deps.probeReachable)
18
+ return Promise.resolve(undefined);
19
+ const key = connection.endpoint.replace(/\/+$/, "");
20
+ let pending = cache.get(key);
21
+ if (!pending) {
22
+ pending = deps.probeReachable(connection);
23
+ cache.set(key, pending);
24
+ }
25
+ return pending;
26
+ }
27
+ function reachabilityEvidence(reach) {
28
+ return reach
29
+ ? { probed: true, reachable: reach.reachable, ...(reach.error ? { error: reach.error } : {}) }
30
+ : { probed: false, reachable: null };
31
+ }
32
+ /**
33
+ * #914: an unreachable LLM engine is a hard `fail` when it is the resolved
34
+ * `default-llm-engine` (extraction and improve depend on it) but only a
35
+ * `warn` elsewhere. Only a reachability warn escalates; a credential warn
36
+ * stays a warn.
37
+ */
38
+ function escalateDefaultLlmEngineFailure(result) {
39
+ if (result.status === "warn" && result.evidence?.reachable === false) {
40
+ return { ...result, status: "fail" };
41
+ }
42
+ return result;
43
+ }
44
+ /** Rolling window the `session-extraction` check reads from the extract ledger (#914). */
45
+ export const SESSION_EXTRACTION_LEDGER_WINDOW_DAYS = 7;
46
+ function credentialAvailable(credential, env, apiKeyFile) {
47
+ if (credential?.required)
48
+ return credential.names.some((name) => Boolean(env[name]?.trim()));
49
+ // #905: an engine with no env descriptor may still require a file-backed
50
+ // credential — probe it too, rather than reporting an unreadable/empty
51
+ // apiKeyFile as available just because it carries no env var names.
52
+ if (apiKeyFile !== undefined)
53
+ return lookupApiKeyFileValue(apiKeyFile) !== undefined;
54
+ return true;
16
55
  }
17
- function runConfiguredEngineProbe(checkName, engineName, config, deps) {
56
+ async function runConfiguredEngineProbe(checkName, engineName, config, deps, reachabilityCache) {
18
57
  if (!engineName) {
19
58
  return {
20
59
  name: checkName,
@@ -57,6 +96,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
57
96
  const fallbackEngine = configuredEngine.llmEngine ?? config.defaults?.llmEngine;
58
97
  let fallback;
59
98
  let fallbackCredential;
99
+ let fallbackApiKeyFile;
60
100
  let sdkRunner;
61
101
  const resolve = deps.resolveEngine ?? resolveEngine;
62
102
  try {
@@ -70,6 +110,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
70
110
  if (sdkRunner?.fallbackConnection && fallbackEngine) {
71
111
  fallback = { kind: "llm", engine: fallbackEngine, connection: sdkRunner.fallbackConnection };
72
112
  fallbackCredential = sdkRunner.fallbackCredential;
113
+ fallbackApiKeyFile = sdkRunner.fallbackApiKeyFile;
73
114
  }
74
115
  else if (fallbackEngine) {
75
116
  try {
@@ -77,6 +118,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
77
118
  if (resolved.kind === "llm") {
78
119
  fallback = resolved;
79
120
  fallbackCredential = resolved.credential;
121
+ fallbackApiKeyFile = resolved.apiKeyFile;
80
122
  }
81
123
  }
82
124
  catch {
@@ -85,59 +127,91 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
85
127
  }
86
128
  const configuredModel = configuredEngine.model;
87
129
  const effectiveModel = sdkRunner?.profile.model ?? configuredModel ?? fallback?.connection.model;
88
- const fallbackCredentialAvailable = credentialAvailable(fallbackCredential, env);
130
+ const fallbackCredentialAvailable = credentialAvailable(fallbackCredential, env, fallbackApiKeyFile);
89
131
  const missing = [
90
132
  !packageAvailable ? "@opencode-ai/sdk package" : undefined,
91
133
  !binaryAvailable ? `${binary} binary` : undefined,
92
134
  fallbackEngine && !fallback ? "configured fallback LLM connection" : undefined,
93
135
  !fallbackCredentialAvailable ? "required fallback credential" : undefined,
94
136
  ].filter((value) => value !== undefined);
137
+ const sdkEvidence = {
138
+ engine: engineName,
139
+ platform: configuredEngine.platform,
140
+ runtimeKind: "sdk",
141
+ binary,
142
+ binaryAvailable,
143
+ package: "@opencode-ai/sdk",
144
+ packageAvailable,
145
+ model: effectiveModel ?? null,
146
+ configuredModel: configuredModel ?? null,
147
+ modelSource: configuredModel ? "sdk" : effectiveModel ? "fallback" : null,
148
+ fallbackEngine: fallbackEngine ?? null,
149
+ fallbackEndpoint: fallback?.connection.endpoint ?? null,
150
+ fallbackModel: fallback?.connection.model ?? null,
151
+ requiredCredentialAvailable: fallbackCredentialAvailable,
152
+ };
153
+ if (missing.length > 0) {
154
+ return {
155
+ name: checkName,
156
+ kind: "deterministic",
157
+ status: "warn",
158
+ confidence: "high",
159
+ message: `SDK engine "${engineName}" is incomplete: missing ${missing.join(", ")}.`,
160
+ evidence: sdkEvidence,
161
+ };
162
+ }
163
+ // The SDK's LLM fallback is the only endpoint this engine talks to;
164
+ // probe it once every local precondition above is satisfied (#914).
165
+ const reach = fallback ? await probeConnectionReachable(fallback.connection, deps, reachabilityCache) : undefined;
95
166
  return {
96
167
  name: checkName,
97
168
  kind: "deterministic",
98
- status: missing.length === 0 ? "pass" : "warn",
169
+ status: reach && !reach.reachable ? "warn" : "pass",
99
170
  confidence: "high",
100
- message: missing.length === 0
171
+ message: !fallback
101
172
  ? `SDK engine "${engineName}" is available.`
102
- : `SDK engine "${engineName}" is incomplete: missing ${missing.join(", ")}.`,
103
- evidence: {
104
- engine: engineName,
105
- platform: configuredEngine.platform,
106
- runtimeKind: "sdk",
107
- binary,
108
- binaryAvailable,
109
- package: "@opencode-ai/sdk",
110
- packageAvailable,
111
- model: effectiveModel ?? null,
112
- configuredModel: configuredModel ?? null,
113
- modelSource: configuredModel ? "sdk" : effectiveModel ? "fallback" : null,
114
- fallbackEngine: fallbackEngine ?? null,
115
- fallbackEndpoint: fallback?.connection.endpoint ?? null,
116
- fallbackModel: fallback?.connection.model ?? null,
117
- requiredCredentialAvailable: fallbackCredentialAvailable,
118
- },
173
+ : !reach
174
+ ? `SDK engine "${engineName}" is available. Reachability was not probed.`
175
+ : reach.reachable
176
+ ? `SDK engine "${engineName}" is available and its LLM fallback is reachable.`
177
+ : `SDK engine "${engineName}" is available, but its LLM fallback is not reachable: ${reach.error ?? "unknown error"}`,
178
+ evidence: fallback ? { ...sdkEvidence, ...reachabilityEvidence(reach) } : sdkEvidence,
119
179
  };
120
180
  }
121
181
  try {
122
182
  const runner = (deps.resolveEngine ?? resolveEngine)(engineName, config);
123
183
  if (runner.kind === "llm") {
124
- const requiredCredentialAvailable = credentialAvailable(runner.credential, env);
184
+ const requiredCredentialAvailable = credentialAvailable(runner.credential, env, runner.apiKeyFile);
185
+ const llmEvidence = {
186
+ engine: engineName,
187
+ platform: null,
188
+ runtimeKind: "llm",
189
+ model: runner.connection.model,
190
+ endpoint: runner.connection.endpoint,
191
+ requiredCredentialAvailable,
192
+ };
193
+ if (!requiredCredentialAvailable) {
194
+ return {
195
+ name: checkName,
196
+ kind: "deterministic",
197
+ status: "warn",
198
+ confidence: "high",
199
+ message: `LLM engine "${engineName}" is configured, but its required credential is unavailable.`,
200
+ evidence: llmEvidence,
201
+ };
202
+ }
203
+ const reach = await probeConnectionReachable(runner.connection, deps, reachabilityCache);
125
204
  return {
126
205
  name: checkName,
127
206
  kind: "deterministic",
128
- status: requiredCredentialAvailable ? "pass" : "warn",
207
+ status: reach && !reach.reachable ? "warn" : "pass",
129
208
  confidence: "high",
130
- message: requiredCredentialAvailable
131
- ? `LLM engine "${engineName}" is configured.`
132
- : `LLM engine "${engineName}" is configured, but its required credential is unavailable.`,
133
- evidence: {
134
- engine: engineName,
135
- platform: null,
136
- runtimeKind: "llm",
137
- model: runner.connection.model,
138
- endpoint: runner.connection.endpoint,
139
- requiredCredentialAvailable,
140
- },
209
+ message: !reach
210
+ ? `LLM engine "${engineName}" is configured. Reachability was not probed.`
211
+ : reach.reachable
212
+ ? `LLM engine "${engineName}" is configured and reachable.`
213
+ : `LLM engine "${engineName}" is not reachable: ${reach.error ?? "unknown error"}`,
214
+ evidence: { ...llmEvidence, ...reachabilityEvidence(reach) },
141
215
  };
142
216
  }
143
217
  const profile = runner.profile;
@@ -188,8 +262,10 @@ function projectEngineProbe(result, name) {
188
262
  }
189
263
  function projectSelectedEngineProbe(availability, engineName, checkName) {
190
264
  const result = availability.get(engineName);
191
- if (result)
192
- return projectEngineProbe(result, checkName);
265
+ if (result) {
266
+ const projected = projectEngineProbe(result, checkName);
267
+ return checkName === "default-llm-engine" ? escalateDefaultLlmEngineFailure(projected) : projected;
268
+ }
193
269
  return {
194
270
  name: checkName,
195
271
  kind: "deterministic",
@@ -235,30 +311,36 @@ function configuredEnginesProjection(engineNames, availability) {
235
311
  evidence: { engines },
236
312
  };
237
313
  }
238
- export function runDefaultEngineProbe(deps = {}) {
314
+ export async function runDefaultEngineProbe(deps = {}) {
239
315
  // Probe the effective view: an install with no `defaults.engine` but a usable
240
316
  // opencode binary DOES have a working default, and reporting otherwise would
241
317
  // contradict what `workflow run` / `task run` actually do.
242
318
  const { config } = withEngineFallback(deps.loadConfig?.() ?? loadConfig(), deps.which);
243
- return runConfiguredEngineProbe("default-engine", config.defaults?.engine, config, deps);
319
+ return runConfiguredEngineProbe("default-engine", config.defaults?.engine, config, deps, new Map());
244
320
  }
245
- export function runDefaultLlmEngineProbe(deps = {}) {
321
+ export async function runDefaultLlmEngineProbe(deps = {}) {
246
322
  const config = deps.loadConfig?.() ?? loadConfig();
247
- return runConfiguredEngineProbe("default-llm-engine", config.defaults?.llmEngine, config, deps);
323
+ const engineName = config.defaults?.llmEngine;
324
+ if (!engineName)
325
+ return unconfiguredEngineProbe("default-llm-engine");
326
+ const result = await runConfiguredEngineProbe("configured-engine", engineName, config, deps, new Map());
327
+ return projectSelectedEngineProbe(new Map([[engineName, result]]), engineName, "default-llm-engine");
248
328
  }
249
329
  /** Probe every explicitly configured engine without exposing connection or model material. */
250
- export function runConfiguredEnginesProbe(deps = {}) {
330
+ export async function runConfiguredEnginesProbe(deps = {}) {
251
331
  const config = deps.loadConfig?.() ?? loadConfig();
252
332
  const engineNames = Object.keys(config.engines ?? {}).sort();
253
- const availability = new Map(engineNames.map((engine) => [engine, runConfiguredEngineProbe("configured-engine", engine, config, deps)]));
333
+ const cache = new Map();
334
+ const availability = new Map(await Promise.all(engineNames.map(async (engine) => [engine, await runConfiguredEngineProbe("configured-engine", engine, config, deps, cache)])));
254
335
  return configuredEnginesProjection(engineNames, availability);
255
336
  }
256
337
  /**
257
338
  * Build the per-health-run availability snapshot. Each distinct selected or
258
- * explicitly configured engine is probed once; the three public checks are
259
- * projections of that immutable result set.
339
+ * explicitly configured engine is probed once (reachability probes further
340
+ * de-duped by endpoint, see {@link ReachabilityCache}); the three public
341
+ * checks are projections of that immutable result set.
260
342
  */
261
- export function runHealthEngineProbes(deps = {}) {
343
+ export async function runHealthEngineProbes(deps = {}) {
262
344
  const configured = deps.loadConfig?.() ?? loadConfig();
263
345
  const effective = withEngineFallback(configured, deps.which).config;
264
346
  const defaultEngineName = effective.defaults?.engine;
@@ -267,7 +349,8 @@ export function runHealthEngineProbes(deps = {}) {
267
349
  const probeNames = [...new Set([...explicitEngineNames, defaultEngineName, defaultLlmEngineName])]
268
350
  .filter((name) => name !== undefined)
269
351
  .sort();
270
- const availability = new Map(probeNames.map((engine) => [engine, runConfiguredEngineProbe("configured-engine", engine, effective, deps)]));
352
+ const cache = new Map();
353
+ const availability = new Map(await Promise.all(probeNames.map(async (engine) => [engine, await runConfiguredEngineProbe("configured-engine", engine, effective, deps, cache)])));
271
354
  return Object.freeze({
272
355
  defaultEngine: defaultEngineName
273
356
  ? projectSelectedEngineProbe(availability, defaultEngineName, "default-engine")
@@ -287,7 +370,7 @@ export function runActiveImproveStrategyProbe(deps = {}) {
287
370
  const unavailableProcesses = Object.entries(plan.processes).flatMap(([name, process]) => {
288
371
  if (!process.enabled || !process.runner)
289
372
  return [];
290
- return credentialAvailable(process.runner.credential, env) ? [] : [name];
373
+ return credentialAvailable(process.runner.credential, env, process.runner.apiKeyFile) ? [] : [name];
291
374
  });
292
375
  if (plan.triageJudgment) {
293
376
  const judgmentCredential = plan.triageJudgment.kind === "llm"
@@ -295,20 +378,41 @@ export function runActiveImproveStrategyProbe(deps = {}) {
295
378
  : plan.triageJudgment.kind === "sdk"
296
379
  ? plan.triageJudgment.fallbackCredential
297
380
  : undefined;
298
- if (!credentialAvailable(judgmentCredential, env))
381
+ const judgmentApiKeyFile = plan.triageJudgment.kind === "llm"
382
+ ? plan.triageJudgment.apiKeyFile
383
+ : plan.triageJudgment.kind === "sdk"
384
+ ? plan.triageJudgment.fallbackApiKeyFile
385
+ : undefined;
386
+ if (!credentialAvailable(judgmentCredential, env, judgmentApiKeyFile)) {
299
387
  unavailableProcesses.push("triage.judgment");
388
+ }
300
389
  }
390
+ // #913: name the engine each process actually resolved to, so a
391
+ // strategy-level `engine` pin that shadows `defaults.llmEngine` is
392
+ // visible on `akm health` instead of requiring config archaeology.
393
+ const engines = {};
394
+ for (const [name, process] of Object.entries(plan.processes)) {
395
+ if (process.enabled && process.runner)
396
+ engines[name] = process.runner.engine;
397
+ }
398
+ if (plan.triageJudgment)
399
+ engines["triage.judgment"] = plan.triageJudgment.engine;
400
+ const engineList = Object.entries(engines)
401
+ .sort(([a], [b]) => a.localeCompare(b))
402
+ .map(([process, engine]) => `${process}: "${engine}"`)
403
+ .join(", ");
301
404
  return {
302
405
  name: "active-improve-strategy",
303
406
  kind: "deterministic",
304
407
  status: unavailableProcesses.length === 0 ? "pass" : "warn",
305
408
  confidence: "high",
306
409
  message: unavailableProcesses.length === 0
307
- ? `Active improve strategy "${plan.strategy.name}" has available process engines.`
308
- : `Active improve strategy "${plan.strategy.name}" has unavailable required credentials for: ${unavailableProcesses.join(", ")}.`,
410
+ ? `Active improve strategy "${plan.strategy.name}" has available process engines${engineList ? ` (${engineList})` : ""}.`
411
+ : `Active improve strategy "${plan.strategy.name}" has unavailable required credentials for: ${unavailableProcesses.join(", ")}${engineList ? ` (engines: ${engineList})` : ""}.`,
309
412
  evidence: {
310
413
  strategy: plan.strategy.name,
311
414
  unavailableProcesses,
415
+ engines,
312
416
  },
313
417
  };
314
418
  }
@@ -421,8 +525,11 @@ export function runSelectedModelAliasesProbe(deps = {}) {
421
525
  const outcomes = selected.map(({ engine, alias, modelMapKey }) => {
422
526
  try {
423
527
  const resolution = resolveModelMapAlias(alias, modelMapKey, modelMap.map);
424
- return resolution.interpretation === "alias"
425
- ? { kind: "alias", evidence: { engine, alias, modelMapKey } }
528
+ if (resolution.interpretation === "alias") {
529
+ return { kind: "alias", evidence: { engine, alias, modelMapKey } };
530
+ }
531
+ return resolution.unmappedForEngine
532
+ ? { kind: "missing", evidence: { engine, alias, modelMapKey } }
426
533
  : { kind: "exact" };
427
534
  }
428
535
  catch {
@@ -648,34 +755,58 @@ export const HEALTH_CHECKS = [
648
755
  },
649
756
  },
650
757
  {
758
+ // #914: derived from the `extract_sessions_seen` LEDGER, not
759
+ // `improve_runs.result_json` — the hook-driven `akm proposal extract
760
+ // --session-id ...` (the standard Claude Code plugin setup) never writes
761
+ // an `improve_runs` row, so the old improve_runs-only source reported
762
+ // "not active" as `pass` unconditionally on a plugin-driven machine, no
763
+ // matter how healthy or broken extraction actually was.
651
764
  name: "session-extraction",
652
765
  channel: "advisory",
653
766
  run: (ctx) => {
654
- const sx = ctx.sessionExtraction;
655
- const sxWarnReasons = [];
656
- if (sx.warnings > 0)
657
- sxWarnReasons.push(`${sx.warnings} harness error(s)`);
658
- if (sx.ran && sx.sessionsScanned >= 5 && sx.proposalsCreated === 0)
659
- sxWarnReasons.push("no proposals generated across scanned sessions");
660
- return {
767
+ const { since: ledgerSince, rows } = ctx.sessionExtractionLedger;
768
+ const days = SESSION_EXTRACTION_LEDGER_WINDOW_DAYS;
769
+ const base = {
661
770
  name: "session-extraction",
662
771
  kind: "heuristic",
663
- status: sxWarnReasons.length > 0 ? "warn" : "pass",
664
- confidence: sx.ran ? "medium" : "low",
665
- message: sx.ran
666
- ? sxWarnReasons.length > 0
667
- ? `Session extraction degraded: ${sxWarnReasons.join("; ")}.`
668
- : `Session extraction healthy: ${sx.sessionsScanned} scanned, ${sx.sessionsExtracted} extracted, ${sx.proposalsCreated} proposal(s) created.`
669
- : "Session extraction not active (feature disabled or no harness available).",
670
- evidence: {
671
- ran: sx.ran,
672
- sessionsScanned: sx.sessionsScanned,
673
- sessionsExtracted: sx.sessionsExtracted,
674
- sessionsSkipped: sx.sessionsSkipped,
675
- proposalsCreated: sx.proposalsCreated,
676
- warnings: sx.warnings,
677
- durationMs: sx.durationMs,
678
- },
772
+ confidence: "medium",
773
+ evidence: { ledgerSince, ledgerRows: rows },
774
+ };
775
+ const totalRows = rows.reduce((sum, row) => sum + row.count, 0);
776
+ if (totalRows === 0) {
777
+ return { ...base, status: "unknown", message: `No extraction recorded in the last ${days} days.` };
778
+ }
779
+ // `read_failed` / `exception` land in the ledger as `failed`, not `skipped`.
780
+ const nothingHarvested = rows.every((row) => row.outcome === "skipped" || row.outcome === "failed");
781
+ const knownSkipReasons = [...new Set(rows.flatMap((row) => (row.skipReason ? [row.skipReason] : [])))].sort();
782
+ const allKnownReasonsAreInfra = knownSkipReasons.every((reason) => EXTRACT_INFRASTRUCTURE_SKIP_REASONS.includes(reason));
783
+ if (nothingHarvested && allKnownReasonsAreInfra) {
784
+ const reasonClauses = knownSkipReasons.map((reason) => {
785
+ const engines = [
786
+ ...new Set(rows.flatMap((row) => (row.skipReason === reason && row.engine ? [row.engine] : []))),
787
+ ].sort();
788
+ return engines.length > 0 ? `${reason} (engine ${engines.map((e) => `"${e}"`).join(", ")})` : reason;
789
+ });
790
+ if (rows.some((row) => row.skipReason === null))
791
+ reasonClauses.push("unknown reason");
792
+ const verb = rows.some((row) => row.outcome === "failed") ? "skipped or failed" : "skipped";
793
+ return {
794
+ ...base,
795
+ status: "warn",
796
+ message: `${totalRows} of ${totalRows} sessions in the last ${days} days ${verb}: ${reasonClauses.join(", ")}.`,
797
+ };
798
+ }
799
+ const outcomeCounts = new Map();
800
+ for (const row of rows)
801
+ outcomeCounts.set(row.outcome, (outcomeCounts.get(row.outcome) ?? 0) + row.count);
802
+ const outcomeSummary = [...outcomeCounts.entries()]
803
+ .sort(([a], [b]) => a.localeCompare(b))
804
+ .map(([outcome, count]) => `${outcome}: ${count}`)
805
+ .join(", ");
806
+ return {
807
+ ...base,
808
+ status: "pass",
809
+ message: `Session extraction active in the last ${days} days (${outcomeSummary}).`,
679
810
  };
680
811
  },
681
812
  },
@@ -76,6 +76,9 @@ function createUnknownImproveMetrics() {
76
76
  coverageGapCount: 0,
77
77
  evalCasesWritten: 0,
78
78
  deadUrlCount: 0,
79
+ deadUrlsChecked: 0,
80
+ deadUrlsTotal: 0,
81
+ deadUrlsSkipped: 0,
79
82
  memorySummary: { eligible: 0, derived: 0 },
80
83
  memoryCleanup: {
81
84
  pruneCandidates: 0,
@@ -333,6 +336,12 @@ function applyMiscCounters(metrics, result) {
333
336
  metrics.evalCasesWritten += toFiniteNumber(result.evalCasesWritten);
334
337
  if (Array.isArray(result.deadUrls))
335
338
  metrics.deadUrlCount += result.deadUrls.length;
339
+ const deadUrlCoverage = result.deadUrlCoverage;
340
+ if (deadUrlCoverage && typeof deadUrlCoverage === "object") {
341
+ metrics.deadUrlsChecked += toFiniteNumber(deadUrlCoverage.checked);
342
+ metrics.deadUrlsTotal += toFiniteNumber(deadUrlCoverage.total);
343
+ metrics.deadUrlsSkipped += toFiniteNumber(deadUrlCoverage.skipped);
344
+ }
336
345
  }
337
346
  function applyMemorySummary(metrics, result) {
338
347
  const memorySummary = result.memorySummary;
@@ -568,6 +577,9 @@ function mergeImproveMetrics(dst, src) {
568
577
  dst.coverageGapCount += src.coverageGapCount;
569
578
  dst.evalCasesWritten += src.evalCasesWritten;
570
579
  dst.deadUrlCount += src.deadUrlCount;
580
+ dst.deadUrlsChecked += src.deadUrlsChecked;
581
+ dst.deadUrlsTotal += src.deadUrlsTotal;
582
+ dst.deadUrlsSkipped += src.deadUrlsSkipped;
571
583
  // NOTE: memorySummary (derived/eligible) is a WHOLE-STASH snapshot recorded on
572
584
  // every run, NOT a per-run increment — summing it across the window inflates
573
585
  // it ~N× (the 1.2M-eligible bug). It is set from the most recent run in