akm-cli 0.9.0-rc.2 → 0.9.0-rc.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/improve-strategies/catchup.json +3 -1
  8. package/dist/assets/improve-strategies/consolidate.json +3 -1
  9. package/dist/assets/improve-strategies/frequent.json +3 -1
  10. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  11. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  12. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  13. package/dist/assets/improve-strategies/quick.json +3 -1
  14. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  15. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  16. package/dist/assets/improve-strategies/synthesize.json +2 -0
  17. package/dist/assets/tasks/core/backup.yml +2 -2
  18. package/dist/cli/config-migrate.js +554 -26
  19. package/dist/cli.js +28 -11
  20. package/dist/commands/backup-cli.js +6 -4
  21. package/dist/commands/config-cli.js +10 -3
  22. package/dist/commands/feedback-cli.js +24 -7
  23. package/dist/commands/health/checks.js +94 -15
  24. package/dist/commands/health/html-report.js +23 -2
  25. package/dist/commands/health/improve-metrics.js +45 -6
  26. package/dist/commands/health/md-report.js +10 -1
  27. package/dist/commands/health/surfaces.js +2 -1
  28. package/dist/commands/health/windows.js +1 -1
  29. package/dist/commands/health.js +1 -1
  30. package/dist/commands/improve/anti-collapse.js +3 -3
  31. package/dist/commands/improve/collapse-detector.js +5 -5
  32. package/dist/commands/improve/consolidate.js +123 -66
  33. package/dist/commands/improve/distill/promote-memory.js +8 -6
  34. package/dist/commands/improve/distill/quality-gate.js +1 -1
  35. package/dist/commands/improve/distill-guards.js +1 -1
  36. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  37. package/dist/commands/improve/distill.js +52 -36
  38. package/dist/commands/improve/eligibility.js +9 -8
  39. package/dist/commands/improve/extract-prompt.js +2 -2
  40. package/dist/commands/improve/extract.js +43 -11
  41. package/dist/commands/improve/improve-auto-accept.js +14 -28
  42. package/dist/commands/improve/improve-cli.js +4 -2
  43. package/dist/commands/improve/improve-strategies.js +2 -1
  44. package/dist/commands/improve/improve.js +49 -8
  45. package/dist/commands/improve/loop-stages.js +11 -6
  46. package/dist/commands/improve/preparation.js +51 -17
  47. package/dist/commands/improve/procedural.js +10 -6
  48. package/dist/commands/improve/recombine.js +31 -7
  49. package/dist/commands/improve/reflect.js +24 -14
  50. package/dist/commands/improve/salience.js +5 -1
  51. package/dist/commands/improve/source-identity.js +43 -0
  52. package/dist/commands/migrate-cli.js +36 -0
  53. package/dist/commands/mv-cli.js +647 -258
  54. package/dist/commands/proposal/drain.js +16 -4
  55. package/dist/commands/proposal/proposal-cli.js +3 -2
  56. package/dist/commands/proposal/proposal.js +16 -55
  57. package/dist/commands/proposal/repository.js +664 -58
  58. package/dist/commands/read/curate.js +4 -2
  59. package/dist/commands/read/knowledge.js +4 -1
  60. package/dist/commands/read/search.js +6 -2
  61. package/dist/commands/read/show.js +8 -7
  62. package/dist/commands/sources/self-update.js +156 -112
  63. package/dist/commands/sources/sources-cli.js +7 -2
  64. package/dist/commands/tasks/default-tasks.js +5 -5
  65. package/dist/commands/tasks/tasks-cli.js +7 -3
  66. package/dist/commands/tasks/tasks.js +261 -68
  67. package/dist/core/common.js +36 -3
  68. package/dist/core/config/config-io.js +2 -2
  69. package/dist/core/config/config-schema.js +15 -3
  70. package/dist/core/config/config.js +14 -13
  71. package/dist/core/file-lock.js +2 -2
  72. package/dist/core/improve-result.js +87 -7
  73. package/dist/core/migration-backup.js +816 -171
  74. package/dist/core/migration-operation.js +44 -0
  75. package/dist/core/state/migrations.js +4 -7
  76. package/dist/core/state-db.js +23 -7
  77. package/dist/core/write-source.js +86 -18
  78. package/dist/indexer/db/db.js +109 -53
  79. package/dist/indexer/index-writer-lock.js +38 -37
  80. package/dist/indexer/index-written-assets.js +73 -56
  81. package/dist/indexer/indexer.js +5 -1
  82. package/dist/indexer/search/search-source.js +2 -2
  83. package/dist/indexer/usage/usage-events.js +8 -2
  84. package/dist/integrations/agent/engine-resolution.js +14 -7
  85. package/dist/scripts/migrate-storage.js +887 -992
  86. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
  87. package/dist/setup/detected-engines.js +7 -13
  88. package/dist/setup/engine-config.js +14 -4
  89. package/dist/setup/setup.js +1 -1
  90. package/dist/setup/steps/connection.js +1 -1
  91. package/dist/setup/steps/tasks.js +1 -1
  92. package/dist/sources/providers/git-stash.js +58 -21
  93. package/dist/sources/providers/git.js +1 -1
  94. package/dist/storage/engines/sqlite-migrations.js +138 -3
  95. package/dist/storage/repositories/events-repository.js +24 -0
  96. package/dist/storage/repositories/proposals-repository.js +14 -0
  97. package/dist/storage/repositories/task-history-repository.js +30 -3
  98. package/dist/tasks/backends/cron.js +71 -38
  99. package/dist/tasks/backends/exec-utils.js +55 -3
  100. package/dist/tasks/backends/launchd.js +241 -50
  101. package/dist/tasks/backends/schtasks.js +434 -59
  102. package/dist/tasks/command-executable.js +93 -0
  103. package/dist/tasks/embedded.js +2 -0
  104. package/dist/tasks/parser.js +142 -6
  105. package/dist/tasks/resolve-akm-bin.js +19 -4
  106. package/dist/tasks/runner.js +258 -93
  107. package/dist/tasks/schedule.js +108 -19
  108. package/dist/tasks/scheduler-invocation.js +86 -0
  109. package/dist/tasks/task-id.js +37 -0
  110. package/dist/workflows/db.js +24 -13
  111. package/dist/workflows/validator.js +24 -2
  112. package/docs/README.md +103 -0
  113. package/docs/migration/release-notes/0.9.0.md +28 -9
  114. package/docs/migration/v0.8-to-v0.9.md +186 -26
  115. package/package.json +2 -3
package/dist/cli.js CHANGED
@@ -2,9 +2,10 @@
2
2
  // This Source Code Form is subject to the terms of the Mozilla Public
3
3
  // License, v. 2.0. If a copy of the MPL was not distributed with this
4
4
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
- // Runtime guard: akm-cli 0.9 runs on Bun (primary) and Node.js >= 20 (#465,
6
- // #560). The runtime boundary (src/runtime.ts, src/storage/database.ts) makes
7
- // the Node path additive. Under Node the CLI must be launched via the
5
+ // Runtime guard: the akm-cli npm package bootstraps with Node.js >= 20.12
6
+ // (#465, #560), then its launcher prefers a working Bun >= 1.0 when available.
7
+ // The runtime boundary (src/runtime.ts, src/storage/database.ts) supports both.
8
+ // Under Node the CLI must be launched via the
8
9
  // `dist/cli-node.mjs` wrapper, which registers the text-import loader hook
9
10
  // before this module graph loads; running `node dist/cli.js` directly still
10
11
  // works for code paths that touch no embedded text asset, but the wrapper is
@@ -18,12 +19,11 @@
18
19
  .map((part) => Number.parseInt(part, 10) || 0);
19
20
  const nodeOk = major > 20 || (major === 20 && (minor > 12 || (minor === 12 && patch >= 0)));
20
21
  if (!nodeOk) {
21
- console.error("\n ERROR: akm-cli requires the Bun runtime (https://bun.sh) or Node.js >= 20.12.\n" +
22
+ console.error("\n ERROR: the akm-cli npm package requires Node.js >= 20.12.\n" +
22
23
  ` Detected Node.js ${process.versions.node ?? "unknown"}.\n` +
23
- " Install options:\n" +
24
- " 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\n" +
25
- " 2. Node: upgrade to Node.js 20.12 or newer (https://nodejs.org)\n" +
26
- " 3. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n");
24
+ " Bun >= 1.0 is optional for execution; it does not replace the Node.js bootstrap.\n" +
25
+ " Upgrade Node.js (https://nodejs.org), or install the runtime-free standalone binary:\n" +
26
+ " curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n");
27
27
  process.exit(1);
28
28
  }
29
29
  }
@@ -60,7 +60,7 @@ process.on("uncaughtException", (err) => {
60
60
  import fs from "node:fs";
61
61
  import path from "node:path";
62
62
  import { defineCommand, runMain } from "citty";
63
- import { findCittyTopLevelCommand } from "./cli/parse-args.js";
63
+ import { findCittyTopLevelCommand, findCittyTopLevelCommandIndex } from "./cli/parse-args.js";
64
64
  import { EXIT_CODES, emitJsonError, output, parseAllFlagValues, runWithJsonErrors } from "./cli/shared.js";
65
65
  import { agentCommand, lintCommand, proposeCommand } from "./commands/agent/contribute-cli.js";
66
66
  import { backupCommand } from "./commands/backup-cli.js";
@@ -75,6 +75,7 @@ import { renderRunsDetailMd, renderWindowCompareMd } from "./commands/health/md-
75
75
  import { parseWindowSpec } from "./commands/health/windows.js";
76
76
  import { extractCommand } from "./commands/improve/extract-cli.js";
77
77
  import { improveCommand } from "./commands/improve/improve-cli.js";
78
+ import { migrateCommand } from "./commands/migrate-cli.js";
78
79
  import { mvCommand } from "./commands/mv-cli.js";
79
80
  import { hintsCommand, lessonsCommand, logCommand } from "./commands/observability-cli.js";
80
81
  import { proposalCommand } from "./commands/proposal/proposal-cli.js";
@@ -92,6 +93,7 @@ import { workflowCommand } from "./commands/workflow-cli.js";
92
93
  import { bestEffort } from "./core/best-effort.js";
93
94
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
94
95
  import { UsageError } from "./core/errors.js";
96
+ import { assertNoPendingMigrationOperation } from "./core/migration-operation.js";
95
97
  import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
96
98
  import { plainize } from "./core/tty.js";
97
99
  import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
@@ -517,6 +519,7 @@ export const main = defineCommand({
517
519
  clone: cloneCommand,
518
520
  mv: mvCommand,
519
521
  registry: registryCommand,
522
+ migrate: migrateCommand,
520
523
  backup: backupCommand,
521
524
  config: configCommand,
522
525
  feedback: feedbackCommand,
@@ -539,13 +542,25 @@ export const main = defineCommand({
539
542
  },
540
543
  });
541
544
  const MAIN_TOP_LEVEL_ARGS = main.args;
545
+ function isTaskRunWithId(argv) {
546
+ const args = argv.slice(2);
547
+ const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
548
+ const command = commandIndex >= 0 ? args[commandIndex] : undefined;
549
+ if (command !== "tasks" && command !== "task")
550
+ return false;
551
+ const taskArgs = args.slice(commandIndex + 1);
552
+ return taskArgs[0] === "run" && typeof taskArgs[1] === "string" && !taskArgs[1].startsWith("-");
553
+ }
542
554
  /** Recovery/setup surfaces must remain reachable when config.json is invalid. */
543
555
  export function shouldBypassConfigStartup(argv) {
544
556
  const args = argv.slice(2);
545
557
  if (args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v"))
546
558
  return true;
547
- const command = findCittyTopLevelCommand([...args], MAIN_TOP_LEVEL_ARGS);
548
- if (command === "setup" || command === "backup")
559
+ const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
560
+ const command = commandIndex >= 0 ? args[commandIndex] : undefined;
561
+ if (command === "setup" || command === "backup" || command === "migrate")
562
+ return true;
563
+ if (isTaskRunWithId(argv))
549
564
  return true;
550
565
  if (command !== "config")
551
566
  return false;
@@ -582,6 +597,8 @@ if (import.meta.main || process.env.AKM_NODE_ENTRY === "1") {
582
597
  // rather than letting the raw exception escape with a stack trace.
583
598
  try {
584
599
  applyEarlyStderrFlags(process.argv);
600
+ if (isTaskRunWithId(process.argv))
601
+ assertNoPendingMigrationOperation();
585
602
  const bypassConfig = shouldBypassConfigStartup(process.argv);
586
603
  initOutputMode(process.argv, bypassConfig ? (DEFAULT_CONFIG.output ?? {}) : (loadConfig().output ?? {}));
587
604
  }
@@ -10,10 +10,10 @@ function requireVersion(value) {
10
10
  }
11
11
  }
12
12
  export const backupCommand = defineGroupCommand({
13
- meta: { name: "backup", description: "Create or restore a versioned migration recovery bundle" },
13
+ meta: { name: "backup", description: "Create or restore a verified migration recovery run" },
14
14
  subCommands: {
15
15
  create: defineJsonCommand({
16
- meta: { name: "create", description: "Create the immutable pre-0.9 migration recovery bundle" },
16
+ meta: { name: "create", description: "Create a unique installation-scoped migration recovery run" },
17
17
  args: {
18
18
  for: { type: "string", required: true, description: "Migration target version (0.9.0)" },
19
19
  },
@@ -30,19 +30,21 @@ export const backupCommand = defineGroupCommand({
30
30
  },
31
31
  }),
32
32
  restore: defineJsonCommand({
33
- meta: { name: "restore", description: "Restore the pre-0.9 config and durable databases" },
33
+ meta: { name: "restore", description: "Restore a recovery run after preserving a rescue snapshot" },
34
34
  args: {
35
35
  for: { type: "string", required: true, description: "Migration target version (0.9.0)" },
36
+ run: { type: "string", description: "Backup run ID (defaults to the newest applicable run)" },
36
37
  confirm: { type: "boolean", default: false, description: "Confirm destructive restoration" },
37
38
  },
38
39
  run({ args }) {
39
40
  requireVersion(args.for);
40
- const result = restoreMigrationBackup(args.confirm);
41
+ const result = restoreMigrationBackup(args.confirm, args.run);
41
42
  output("backup", {
42
43
  action: "restore",
43
44
  for: MIGRATION_BACKUP_VERSION,
44
45
  path: result.path,
45
46
  restored: true,
47
+ rescuePath: result.rescuePath,
46
48
  manifest: result.manifest,
47
49
  });
48
50
  },
@@ -242,11 +242,18 @@ export const configCommand = defineGroupCommand({
242
242
  migrate: defineJsonCommand({
243
243
  meta: {
244
244
  name: "migrate",
245
- description: "Diagnose whether the user config is already in the current schema. Never writes or translates config.",
245
+ description: "Compatibility alias for the canonical top-level migrate status/apply coordinator.",
246
246
  },
247
- async run() {
247
+ args: {
248
+ config: {
249
+ type: "string",
250
+ description: "Path to an operator-prepared current-version config to validate and install",
251
+ },
252
+ dryRun: { type: "boolean", default: false, description: "Validate and report without changing files" },
253
+ },
254
+ async run({ args }) {
248
255
  const { runConfigMigrate } = await import("../cli/config-migrate.js");
249
- await runConfigMigrate();
256
+ await runConfigMigrate({ preparedConfigPath: args.config, dryRun: args.dryRun });
250
257
  },
251
258
  }),
252
259
  enable: defineJsonCommand({
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
6
- import { parseAssetRef } from "../core/asset/asset-ref.js";
6
+ import { parseAssetRef, refToString } from "../core/asset/asset-ref.js";
7
7
  import { assembleAsset } from "../core/asset/asset-serialize.js";
8
8
  import { parseFrontmatter, parseFrontmatterBlock } from "../core/asset/frontmatter.js";
9
9
  import { writeFileAtomic } from "../core/common.js";
@@ -12,7 +12,8 @@ import { UsageError } from "../core/errors.js";
12
12
  import { appendEvent } from "../core/events.js";
13
13
  import { getDbPath } from "../core/paths.js";
14
14
  import { warn } from "../core/warn.js";
15
- import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, getEntryFilePathById, openExistingDatabase, } from "../indexer/db/db.js";
15
+ import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, getEntryById, getEntryFilePathById, openExistingDatabase, } from "../indexer/db/db.js";
16
+ import { resolveSourceEntries } from "../indexer/search/search-source.js";
16
17
  import { countFeedbackSignals, insertUsageEvent } from "../indexer/usage/usage-events.js";
17
18
  // ── Tag validation ────────────────────────────────────────────────────────────
18
19
  const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
@@ -156,7 +157,7 @@ export const feedbackCommand = defineJsonCommand({
156
157
  if (!ref) {
157
158
  throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skill:deploy` and either --positive or --negative.");
158
159
  }
159
- parseAssetRef(ref);
160
+ const parsedRef = parseAssetRef(ref);
160
161
  if (args.positive && args.negative) {
161
162
  throw new UsageError("Specify either --positive or --negative, not both.");
162
163
  }
@@ -216,9 +217,19 @@ export const feedbackCommand = defineJsonCommand({
216
217
  // background reindex it spawned — now that ensureIndex is removed, holding
217
218
  // the lock only causes feedback to block for the full improve run duration.
218
219
  let utilityResult;
220
+ let durableRef = ref;
219
221
  const db = openExistingDatabase();
220
222
  try {
221
- const entryId = findEntryIdByRef(db, ref);
223
+ const config = loadConfig();
224
+ const sources = resolveSourceEntries(undefined, config);
225
+ const requestedSource = parsedRef.origin
226
+ ? sources.find((source) => source.registryId === parsedRef.origin ||
227
+ ((parsedRef.origin === "local" || parsedRef.origin === "stash") && source === sources[0]))
228
+ : undefined;
229
+ if (parsedRef.origin && !requestedSource) {
230
+ throw new UsageError(`Source "${parsedRef.origin}" is not configured.`, "INVALID_FLAG_VALUE");
231
+ }
232
+ const entryId = findEntryIdByRef(db, ref, requestedSource?.path);
222
233
  if (entryId === undefined) {
223
234
  throw new UsageError(`Ref "${ref}" is not in the index. ` +
224
235
  "Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
@@ -228,9 +239,15 @@ export const feedbackCommand = defineJsonCommand({
228
239
  // For negative signals, the score is adjusted the next time `akm index`
229
240
  // runs — the signal is durable in the DB but does NOT suppress ranking
230
241
  // in search results until after reindexing.
242
+ const indexedEntry = getEntryById(db, entryId);
243
+ const source = sources.find((candidate) => candidate.path === indexedEntry?.stashDir);
244
+ durableRef = refToString({
245
+ ...parsedRef,
246
+ origin: parsedRef.origin ?? source?.registryId ?? "stash",
247
+ });
231
248
  insertUsageEvent(db, {
232
249
  event_type: "feedback",
233
- entry_ref: ref,
250
+ entry_ref: durableRef,
234
251
  entry_id: entryId,
235
252
  signal,
236
253
  metadata: metadataStr,
@@ -253,7 +270,7 @@ export const feedbackCommand = defineJsonCommand({
253
270
  }
254
271
  appendEvent({
255
272
  eventType: "feedback",
256
- ref,
273
+ ref: durableRef,
257
274
  metadata: metadataObj,
258
275
  });
259
276
  // F-5 / #386: When a high-utility asset crosses below the review threshold,
@@ -268,7 +285,7 @@ export const feedbackCommand = defineJsonCommand({
268
285
  try {
269
286
  appendEvent({
270
287
  eventType: "improve_review_needed",
271
- ref,
288
+ ref: durableRef,
272
289
  metadata: {
273
290
  previousUtility: utilityResult.previousUtility,
274
291
  nextUtility: utilityResult.nextUtility,
@@ -6,20 +6,25 @@ import { createRequire } from "node:module";
6
6
  import { loadConfig } from "../../core/config/config.js";
7
7
  import { resolveEngine } from "../../integrations/agent/engine-resolution.js";
8
8
  import { resolveModel } from "../../integrations/agent/model-aliases.js";
9
+ import { resolveImprovePlan } from "../improve/improve-strategies.js";
9
10
  import { TASK_FAIL_RATE_WARN, } from "./types.js";
10
11
  const ACTIVE_RUN_WARN_MS = 15 * 60 * 1000;
11
- export function runDefaultEngineProbe(deps = {}) {
12
- const config = deps.loadConfig?.() ?? loadConfig();
13
- const engineName = config.defaults?.engine ?? config.defaults?.llmEngine;
12
+ function credentialAvailable(credential, env) {
13
+ return !credential?.required || credential.names.some((name) => Boolean(env[name]?.trim()));
14
+ }
15
+ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
14
16
  if (!engineName) {
15
17
  return {
16
- name: "default-engine",
18
+ name: checkName,
17
19
  kind: "deterministic",
18
20
  status: "unknown",
19
21
  confidence: "high",
20
- message: "No default engine is configured.",
22
+ message: checkName === "default-llm-engine"
23
+ ? "No default LLM engine is configured."
24
+ : "No default engine is configured.",
21
25
  };
22
26
  }
27
+ const env = deps.env ?? process.env;
23
28
  const configuredEngine = config.engines?.[engineName];
24
29
  if (configuredEngine?.kind === "agent" && configuredEngine.platform === "opencode-sdk") {
25
30
  let packageAvailable = false;
@@ -36,6 +41,7 @@ export function runDefaultEngineProbe(deps = {}) {
36
41
  const binaryAvailable = (version.status ?? 1) === 0;
37
42
  const fallbackEngine = configuredEngine.llmEngine ?? config.defaults?.llmEngine;
38
43
  let fallback;
44
+ let fallbackCredential;
39
45
  let sdkRunner;
40
46
  const resolve = deps.resolveEngine ?? resolveEngine;
41
47
  try {
@@ -48,12 +54,15 @@ export function runDefaultEngineProbe(deps = {}) {
48
54
  }
49
55
  if (sdkRunner?.fallbackConnection) {
50
56
  fallback = { kind: "llm", connection: sdkRunner.fallbackConnection };
57
+ fallbackCredential = sdkRunner.fallbackCredential;
51
58
  }
52
59
  else if (fallbackEngine) {
53
60
  try {
54
61
  const resolved = resolve(fallbackEngine, config);
55
- if (resolved.kind === "llm")
62
+ if (resolved.kind === "llm") {
56
63
  fallback = resolved;
64
+ fallbackCredential = resolved.credential;
65
+ }
57
66
  }
58
67
  catch {
59
68
  fallback = undefined;
@@ -63,14 +72,15 @@ export function runDefaultEngineProbe(deps = {}) {
63
72
  ? resolveModel(configuredEngine.model, "opencode-sdk", configuredEngine.modelAliases, config.modelAliases)
64
73
  : undefined;
65
74
  const effectiveModel = sdkRunner?.profile.model ?? configuredModel ?? fallback?.connection.model;
75
+ const fallbackCredentialAvailable = credentialAvailable(fallbackCredential, env);
66
76
  const missing = [
67
77
  !packageAvailable ? "@opencode-ai/sdk package" : undefined,
68
78
  !binaryAvailable ? `${binary} binary` : undefined,
69
- !fallback ? "fallback LLM connection" : undefined,
70
- !effectiveModel ? "effective SDK model" : undefined,
79
+ fallbackEngine && !fallback ? "configured fallback LLM connection" : undefined,
80
+ !fallbackCredentialAvailable ? "required fallback credential" : undefined,
71
81
  ].filter((value) => value !== undefined);
72
82
  return {
73
- name: "default-engine",
83
+ name: checkName,
74
84
  kind: "deterministic",
75
85
  status: missing.length === 0 ? "pass" : "warn",
76
86
  confidence: "high",
@@ -91,24 +101,29 @@ export function runDefaultEngineProbe(deps = {}) {
91
101
  fallbackEngine: fallbackEngine ?? null,
92
102
  fallbackEndpoint: fallback?.connection.endpoint ?? null,
93
103
  fallbackModel: fallback?.connection.model ?? null,
104
+ requiredCredentialAvailable: fallbackCredentialAvailable,
94
105
  },
95
106
  };
96
107
  }
97
108
  try {
98
109
  const runner = (deps.resolveEngine ?? resolveEngine)(engineName, config);
99
110
  if (runner.kind === "llm") {
111
+ const requiredCredentialAvailable = credentialAvailable(runner.credential, env);
100
112
  return {
101
- name: "default-engine",
113
+ name: checkName,
102
114
  kind: "deterministic",
103
- status: "pass",
115
+ status: requiredCredentialAvailable ? "pass" : "warn",
104
116
  confidence: "high",
105
- message: `LLM engine "${engineName}" is configured.`,
117
+ message: requiredCredentialAvailable
118
+ ? `LLM engine "${engineName}" is configured.`
119
+ : `LLM engine "${engineName}" is configured, but its required credential is unavailable.`,
106
120
  evidence: {
107
121
  engine: engineName,
108
122
  platform: null,
109
123
  runtimeKind: "llm",
110
124
  model: runner.connection.model,
111
125
  endpoint: runner.connection.endpoint,
126
+ requiredCredentialAvailable,
112
127
  },
113
128
  };
114
129
  }
@@ -118,7 +133,7 @@ export function runDefaultEngineProbe(deps = {}) {
118
133
  const version = (deps.spawnSync ?? spawnSync)(profile.bin, ["--version"], { encoding: "utf8", timeout: 5_000 });
119
134
  if ((version.status ?? 1) !== 0) {
120
135
  return {
121
- name: "default-engine",
136
+ name: checkName,
122
137
  kind: "deterministic",
123
138
  status: "warn",
124
139
  confidence: "medium",
@@ -132,7 +147,7 @@ export function runDefaultEngineProbe(deps = {}) {
132
147
  };
133
148
  }
134
149
  return {
135
- name: "default-engine",
150
+ name: checkName,
136
151
  kind: "deterministic",
137
152
  status: "pass",
138
153
  confidence: "high",
@@ -147,7 +162,7 @@ export function runDefaultEngineProbe(deps = {}) {
147
162
  }
148
163
  catch (error) {
149
164
  return {
150
- name: "default-engine",
165
+ name: checkName,
151
166
  kind: "deterministic",
152
167
  status: "warn",
153
168
  confidence: "high",
@@ -155,6 +170,60 @@ export function runDefaultEngineProbe(deps = {}) {
155
170
  };
156
171
  }
157
172
  }
173
+ export function runDefaultEngineProbe(deps = {}) {
174
+ const config = deps.loadConfig?.() ?? loadConfig();
175
+ return runConfiguredEngineProbe("default-engine", config.defaults?.engine, config, deps);
176
+ }
177
+ export function runDefaultLlmEngineProbe(deps = {}) {
178
+ const config = deps.loadConfig?.() ?? loadConfig();
179
+ return runConfiguredEngineProbe("default-llm-engine", config.defaults?.llmEngine, config, deps);
180
+ }
181
+ export function runActiveImproveStrategyProbe(deps = {}) {
182
+ const config = deps.loadConfig?.() ?? loadConfig();
183
+ const strategyName = config.defaults?.improveStrategy ?? "default";
184
+ try {
185
+ const plan = resolveImprovePlan(strategyName, config);
186
+ const env = deps.env ?? process.env;
187
+ const unavailableProcesses = Object.entries(plan.processes).flatMap(([name, process]) => {
188
+ if (!process.enabled || !process.runner)
189
+ return [];
190
+ return credentialAvailable(process.runner.credential, env) ? [] : [name];
191
+ });
192
+ if (plan.triageJudgment) {
193
+ const judgmentCredential = plan.triageJudgment.kind === "llm"
194
+ ? plan.triageJudgment.credential
195
+ : plan.triageJudgment.kind === "sdk"
196
+ ? plan.triageJudgment.fallbackCredential
197
+ : undefined;
198
+ if (!credentialAvailable(judgmentCredential, env))
199
+ unavailableProcesses.push("triage.judgment");
200
+ }
201
+ return {
202
+ name: "active-improve-strategy",
203
+ kind: "deterministic",
204
+ status: unavailableProcesses.length === 0 ? "pass" : "warn",
205
+ confidence: "high",
206
+ message: unavailableProcesses.length === 0
207
+ ? `Active improve strategy "${plan.strategy.name}" has available process engines.`
208
+ : `Active improve strategy "${plan.strategy.name}" has unavailable required credentials for: ${unavailableProcesses.join(", ")}.`,
209
+ evidence: {
210
+ strategy: plan.strategy.name,
211
+ unavailableProcesses,
212
+ },
213
+ };
214
+ }
215
+ catch (error) {
216
+ const explicitlyConfigured = config.defaults?.improveStrategy !== undefined || Object.keys(config.improve?.strategies ?? {}).length > 0;
217
+ return {
218
+ name: "active-improve-strategy",
219
+ kind: "deterministic",
220
+ status: explicitlyConfigured ? "warn" : "unknown",
221
+ confidence: "high",
222
+ message: `Active improve strategy "${strategyName}" is unavailable: ${error instanceof Error ? error.message : String(error)}`,
223
+ evidence: { strategy: strategyName, unavailableProcesses: [] },
224
+ };
225
+ }
226
+ }
158
227
  /**
159
228
  * The ordered health-check registry. ORDER IS LOAD-BEARING: `akmHealth`
160
229
  * iterates this array and appends to hardChecks/advisories in sequence, so the
@@ -236,6 +305,16 @@ export const HEALTH_CHECKS = [
236
305
  channel: "hard",
237
306
  run: () => runDefaultEngineProbe(),
238
307
  },
308
+ {
309
+ name: "default-llm-engine",
310
+ channel: "hard",
311
+ run: () => runDefaultLlmEngineProbe(),
312
+ },
313
+ {
314
+ name: "active-improve-strategy",
315
+ channel: "hard",
316
+ run: () => runActiveImproveStrategyProbe(),
317
+ },
239
318
  {
240
319
  // C2 (13-bus-factor): the cron task-failure rate was computed and rendered
241
320
  // in the HTML report but never surfaced as an advisory, so a sustained
@@ -92,6 +92,8 @@ function reshapeRun(r) {
92
92
  const geMs = ge.durationMs || 0;
93
93
  return {
94
94
  id: r.id,
95
+ resultStatus: r.resultStatus ?? "valid",
96
+ resultComplete: r.resultComplete ?? (r.resultStatus === undefined || r.resultStatus === "valid"),
95
97
  taskId: r.taskId ?? "manual",
96
98
  strategy: r.strategy,
97
99
  legacyProfile: r.legacyProfile,
@@ -262,7 +264,10 @@ function buildEchartsTag(opts) {
262
264
  */
263
265
  export function buildHealthHtmlReplacements(result, opts) {
264
266
  const deltas = opts.deltas ?? {};
265
- const runs = (result.runs ?? []).map(reshapeRun).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
267
+ const compareRuns = (a, b) => a.startedAt.localeCompare(b.startedAt) || a.completedAt.localeCompare(b.completedAt) || a.id.localeCompare(b.id);
268
+ const allRuns = (result.runs ?? []).map(reshapeRun).sort(compareRuns);
269
+ const invalidRuns = allRuns.filter((run) => run.resultStatus === "invalid");
270
+ const runs = allRuns.filter((run) => run.resultStatus !== "invalid");
266
271
  const improve = result.improve;
267
272
  const proposals = opts.proposals;
268
273
  const sem = readSemSearch(result.advisories);
@@ -320,7 +325,10 @@ export function buildHealthHtmlReplacements(result, opts) {
320
325
  const reportTitle = reportDate ? `AKM Health Report — ${reportDate}` : "AKM Health Report";
321
326
  const lastRun = runs[runs.length - 1];
322
327
  const generatedAt = lastRun ? lastRun.completedAt || lastRun.startedAt || sinceIso : sinceIso;
323
- const latest = [...runs].reverse().find((r) => r.ok) ?? lastRun;
328
+ const latest = [...runs]
329
+ .filter((run) => run.resultComplete)
330
+ .sort(compareRuns)
331
+ .at(-1);
324
332
  // Freshness: surface the newest run's timestamp + the generated-at anchor in
325
333
  // the exec card. Staleness is computed deterministically (no Date.now()) from
326
334
  // the gap between the window start (`since`) and the newest run we have: if no
@@ -389,6 +397,9 @@ export function buildHealthHtmlReplacements(result, opts) {
389
397
  li("Report window", esc(opts.window)),
390
398
  li("Compare window", esc(opts.compare)),
391
399
  li("Runs", `${num(totalRuns)} (${failedRuns} failed)`),
400
+ li("Included result rows", num(improve.resultRows?.included ?? totalRuns)),
401
+ li("Normalized result rows", num(improve.resultRows?.normalized ?? 0)),
402
+ li("Invalid result rows skipped", num(improve.resultRows?.skipped.invalid ?? invalidRuns.length)),
392
403
  li("Stash derived", `<abbr title="Whole-stash recount of derived assets at report time — not a per-run sum.">${num(improve.memorySummary.derived)}</abbr>`),
393
404
  li("Stash eligible", `<abbr title="Whole-stash recount of eligible assets at report time — not a per-run sum.">${num(improve.memorySummary.eligible)}</abbr>`),
394
405
  li("Pending proposals", String(proposals.length)),
@@ -655,6 +666,16 @@ export function buildHealthHtmlReplacements(result, opts) {
655
666
  seen.add(item.key);
656
667
  items.push(item);
657
668
  };
669
+ if (invalidRuns.length > 0) {
670
+ pushItem({
671
+ key: "invalid-improve-result-rows",
672
+ prio: "P2",
673
+ cls: "warn",
674
+ title: `${invalidRuns.length} invalid improve result row${invalidRuns.length === 1 ? "" : "s"} skipped`,
675
+ descHtml: `Excluded from result-derived metrics: ${invalidRuns.map((run) => `<code>${esc(run.id)}</code>`).join(", ")}.`,
676
+ remedy: `akm health --since=${opts.window} --group-by run`,
677
+ });
678
+ }
658
679
  // Hard advisories from the health check engine (own remediation in message).
659
680
  for (const a of result.advisories) {
660
681
  if (a.status !== "warn" && a.status !== "fail")
@@ -25,6 +25,7 @@ function createUnknownImproveMetrics() {
25
25
  completed: 0,
26
26
  skipped: 0,
27
27
  skipReasons: {},
28
+ resultRows: { total: 0, included: 0, normalized: 0, skipped: { invalid: 0 } },
28
29
  plannedRefs: 0,
29
30
  strategyFilteredRefs: 0,
30
31
  actions: {
@@ -605,6 +606,15 @@ function mergeImproveMetrics(dst, src) {
605
606
  // totalAssets is intentionally NOT merged here — set from the most recent run in summarizeImproveRuns.
606
607
  dst.coverage.acceptedProposals += src.coverage.acceptedProposals;
607
608
  }
609
+ function compareImproveRunRecency(a, b) {
610
+ const started = a.started_at.localeCompare(b.started_at);
611
+ if (started !== 0)
612
+ return started;
613
+ const completed = (a.completed_at ?? "").localeCompare(b.completed_at ?? "");
614
+ if (completed !== 0)
615
+ return completed;
616
+ return a.id.localeCompare(b.id);
617
+ }
608
618
  export function summarizeImproveRuns(db, since, until) {
609
619
  const accum = createUnknownImproveMetrics();
610
620
  const rows = queryImproveRuns(db, since, until);
@@ -618,16 +628,33 @@ export function summarizeImproveRuns(db, since, until) {
618
628
  };
619
629
  // memorySummary is a whole-stash snapshot per run, so the window value is the
620
630
  // MOST RECENT run's snapshot (current state) — not a sum across runs.
621
- let latestStartMs = Number.NEGATIVE_INFINITY;
631
+ let latestCompleteRow;
622
632
  let latestMemorySummary;
623
633
  let latestStrategyFilteredRefs = 0;
634
+ if (!accum.resultRows)
635
+ throw new Error("invariant: improve result-row accounting was not initialized");
636
+ accum.resultRows.total = rows.length;
624
637
  for (const row of rows) {
625
- const result = decodeImproveResult(row.result_json).envelope;
638
+ let decoded;
639
+ try {
640
+ decoded = decodeImproveResult(row.result_json);
641
+ }
642
+ catch {
643
+ accum.resultRows.skipped.invalid += 1;
644
+ continue;
645
+ }
646
+ accum.resultRows.included += 1;
647
+ if (decoded.normalizedLegacyPartial)
648
+ accum.resultRows.normalized += 1;
649
+ const result = decoded.envelope;
626
650
  const perRow = projectRunMetrics(result);
627
651
  mergeImproveMetrics(accum, perRow);
628
652
  const startMs = new Date(row.started_at).getTime();
629
- if (Number.isFinite(startMs) && startMs >= latestStartMs) {
630
- latestStartMs = startMs;
653
+ if (!decoded.normalizedLegacyPartial &&
654
+ result.terminated === undefined &&
655
+ Number.isFinite(startMs) &&
656
+ (latestCompleteRow === undefined || compareImproveRunRecency(row, latestCompleteRow) > 0)) {
657
+ latestCompleteRow = row;
631
658
  latestMemorySummary = perRow.memorySummary;
632
659
  latestStrategyFilteredRefs = perRow.strategyFilteredRefs;
633
660
  }
@@ -681,8 +708,18 @@ export function summarizePhaseDurations(samples) {
681
708
  * Used by `akm health --detail per-run`.
682
709
  */
683
710
  export function projectImproveRunSummary(row, wallTimeMs, taskId) {
684
- const decoded = decodeImproveResult(row.result_json);
685
- const result = decoded.envelope;
711
+ let result = {};
712
+ let resultStatus = "invalid";
713
+ try {
714
+ const decoded = decodeImproveResult(row.result_json);
715
+ result = decoded.envelope;
716
+ resultStatus = decoded.normalizedLegacyPartial ? "normalized" : "valid";
717
+ }
718
+ catch {
719
+ // Keep the persisted row visible in per-run output, but do not project its
720
+ // unknown payload or admit its duration to result-derived denominators.
721
+ wallTimeMs = 0;
722
+ }
686
723
  const perRow = projectRunMetrics(result);
687
724
  finalizeImproveMetrics(perRow);
688
725
  const orphansPurged = toFiniteNumber(result.orphansPurged);
@@ -695,6 +732,8 @@ export function projectImproveRunSummary(row, wallTimeMs, taskId) {
695
732
  completedAt: row.completed_at,
696
733
  wallTimeMs,
697
734
  ok: row.ok === 1,
735
+ resultStatus,
736
+ resultComplete: resultStatus === "valid" && result.terminated === undefined,
698
737
  strategy: row.strategy,
699
738
  legacyProfile: row.legacyProfile,
700
739
  scope: {
@@ -38,6 +38,7 @@ export function renderRunsDetailMd(runs) {
38
38
  "graph_f/e/r",
39
39
  "orphans",
40
40
  "lint_f/fl",
41
+ "result_status",
41
42
  ];
42
43
  const rows = runs.map((r) => {
43
44
  const totalActions = r.actions.reflect.ok +
@@ -66,6 +67,7 @@ export function renderRunsDetailMd(runs) {
66
67
  `${r.graphExtraction.extractedFiles}/${r.graphExtraction.entities}/${r.graphExtraction.relations}`,
67
68
  String(r.orphansPurged),
68
69
  `${r.lintFixed}/${r.lintFlagged}`,
70
+ r.resultStatus ?? "valid",
69
71
  ];
70
72
  });
71
73
  return renderTable(headers, rows);
@@ -89,7 +91,14 @@ export function renderWindowCompareMd(windows, deltas) {
89
91
  "improve.memoryInference.skippedNoFacts",
90
92
  ]);
91
93
  const rows = [];
92
- for (const path of INTERESTING_DELTA_PATHS) {
94
+ const paths = [
95
+ "runs",
96
+ "improve.resultRows.included",
97
+ "improve.resultRows.normalized",
98
+ "improve.resultRows.skipped.invalid",
99
+ ...INTERESTING_DELTA_PATHS,
100
+ ];
101
+ for (const path of paths) {
93
102
  const values = windows.map((w) => String(readNumericPath(w, path)));
94
103
  const delta = deltas?.[path];
95
104
  let deltaStr = "—";
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit } from "../../core/common.js";
21
22
  import { CURRENT_CONFIG_VERSION } from "../../core/config/config-schema.js";
22
23
  import { compareConfigVersion } from "../../core/config/config-version.js";
23
24
  const GROUP_OTHER_BITS = 0o077;
@@ -98,7 +99,7 @@ export function collectSecretPermsAdvisory(input, platform = process.platform) {
98
99
  export function collectConfigSkewAdvisory(configPath) {
99
100
  let raw;
100
101
  try {
101
- raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
102
+ raw = JSON.parse(readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file"));
102
103
  }
103
104
  catch {
104
105
  return undefined;