akm-cli 0.9.0-rc.3 → 0.9.0-rc.5

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 (55) hide show
  1. package/CHANGELOG.md +12 -4
  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/help/help-improve.md +3 -0
  8. package/dist/cli.js +25 -10
  9. package/dist/commands/health/html-report.js +23 -2
  10. package/dist/commands/health/improve-metrics.js +45 -6
  11. package/dist/commands/health/md-report.js +10 -1
  12. package/dist/commands/health/windows.js +1 -1
  13. package/dist/commands/health.js +1 -1
  14. package/dist/commands/improve/distill/promote-memory.js +6 -2
  15. package/dist/commands/improve/distill/quality-gate.js +94 -31
  16. package/dist/commands/improve/distill.js +7 -2
  17. package/dist/commands/improve/extract.js +5 -2
  18. package/dist/commands/improve/improve-auto-accept.js +2 -2
  19. package/dist/commands/improve/improve.js +158 -176
  20. package/dist/commands/improve/locks.js +22 -94
  21. package/dist/commands/improve/loop-stages.js +15 -10
  22. package/dist/commands/improve/preparation.js +21 -17
  23. package/dist/commands/improve/proactive-maintenance.js +4 -6
  24. package/dist/commands/improve/reflect.js +40 -67
  25. package/dist/commands/tasks/default-tasks.js +5 -5
  26. package/dist/commands/tasks/tasks-cli.js +7 -3
  27. package/dist/commands/tasks/tasks.js +261 -68
  28. package/dist/core/extra-params.js +1 -0
  29. package/dist/core/improve-result.js +87 -7
  30. package/dist/core/maintenance-barrier.js +16 -0
  31. package/dist/core/state-db.js +1 -0
  32. package/dist/core/write-source.js +1 -0
  33. package/dist/indexer/ensure-index.js +6 -4
  34. package/dist/llm/client.js +7 -5
  35. package/dist/schemas/akm-task.json +2 -2
  36. package/dist/schemas/akm-workflow.json +2 -2
  37. package/dist/scripts/migrate-storage.js +23 -4
  38. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +91 -9
  39. package/dist/storage/repositories/task-history-repository.js +30 -3
  40. package/dist/tasks/backends/cron.js +71 -38
  41. package/dist/tasks/backends/exec-utils.js +55 -3
  42. package/dist/tasks/backends/launchd.js +241 -50
  43. package/dist/tasks/backends/schtasks.js +434 -59
  44. package/dist/tasks/command-executable.js +93 -0
  45. package/dist/tasks/parser.js +142 -6
  46. package/dist/tasks/resolve-akm-bin.js +19 -4
  47. package/dist/tasks/runner.js +258 -93
  48. package/dist/tasks/schedule.js +108 -19
  49. package/dist/tasks/scheduler-invocation.js +86 -0
  50. package/dist/tasks/task-id.js +37 -0
  51. package/docs/migration/release-notes/0.9.0.md +4 -2
  52. package/docs/migration/v0.8-to-v0.9.md +64 -9
  53. package/package.json +2 -3
  54. package/schemas/akm-task.json +2 -2
  55. package/schemas/akm-workflow.json +2 -2
package/CHANGELOG.md CHANGED
@@ -350,6 +350,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
350
350
 
351
351
  ### Fixed
352
352
 
353
+ - **Improve RC stabilization.** Restored one ownership-safe whole-run lock from
354
+ triage through final sync; `--skip-if-locked` is a true no-op; the run deadline
355
+ now starts before indexing and reaches index waits, generation, reindexing, and
356
+ quality judges; reflect judges the sanitized final candidate with bounded
357
+ changed-region context; write-target selectors no longer replace durable source
358
+ identity; and vLLM thinking controls cannot be overridden through `extraParams`.
353
359
  - **Proposal promotion, reversion, and rejection are durable and recoverable.**
354
360
  Acceptance and reversion persist target ownership and content fingerprints,
355
361
  publish atomically across filesystem layouts, index immediately, commit exact
@@ -1234,14 +1240,16 @@ proposal and log storage, `--format html` output, and per-stage LLM telemetry.
1234
1240
 
1235
1241
  ### Added
1236
1242
 
1237
- - **Cross-runtime: akm now runs on Node.js (≥ 20) in addition to Bun** (#560,
1243
+ - **Cross-runtime: akm now runs on Node.js >= 20.12 in addition to Bun** (#560,
1238
1244
  #465). A two-file runtime boundary (`src/storage/database.ts` owns SQLite via
1239
1245
  `bun:sqlite` on Bun / `better-sqlite3` on Node; `src/runtime.ts` owns every
1240
1246
  `Bun.*` API) contains all runtime-specific code, enforced by a lint guard so it
1241
1247
  cannot leak back out. A CI `node-smoke` matrix runs the built CLI under Node
1242
- 20 and 22. **Minimum Node is 20** — the prompts dependency (`@clack/core`) uses
1243
- `node:util.styleText`, added in Node 20.12; Node 18 is EOL and unsupported.
1244
- Bun remains the primary/default runtime.
1248
+ 20 and 22. The prompts dependency (`@clack/core`) uses `node:util.styleText`,
1249
+ added in Node 20.12; Node 18 is EOL and unsupported. The npm package uses Node
1250
+ as its bootstrap and prefers a working Bun >= 1.0 for execution when both are
1251
+ available. Old, unusable, or absent Bun installations fall back to Node.js;
1252
+ standalone binaries remain runtime-free.
1245
1253
  - **`session` asset type — agent sessions are now searchable** (#561). The
1246
1254
  `extract` pass, after distilling memory proposals from a session, additionally
1247
1255
  writes the session itself as a first-class `session` asset
package/README.md CHANGED
@@ -24,13 +24,7 @@ curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh |
24
24
  irm https://github.com/itlackey/akm/releases/latest/download/install.ps1 | iex
25
25
  ```
26
26
 
27
- **Option 2 — Bun (requires [Bun](https://bun.sh) >= 1.0):**
28
-
29
- ```sh
30
- bun install -g akm-cli
31
- ```
32
-
33
- **Option 3 — Node.js (requires Node.js >= 20.12):**
27
+ **Option 2 — npm package (requires [Node.js](https://nodejs.org) >= 20.12):**
34
28
 
35
29
  ```sh
36
30
  npm install -g akm-cli
@@ -38,7 +32,12 @@ npm install -g akm-cli
38
32
 
39
33
  Upgrade in place with `akm upgrade`.
40
34
 
41
- > **AKM 0.9.0 supports three install paths:** prebuilt binary, Bun, or Node.js >= 20.12.
35
+ The npm package always uses Node.js to bootstrap its cross-platform command.
36
+ If a working [Bun](https://bun.sh) >= 1.0 is also on `PATH`, the launcher
37
+ prefers Bun for execution; old, unusable, or absent Bun installations fall back
38
+ to Node.js. Node.js remains required for the npm package. The standalone
39
+ binaries are runtime-free.
40
+
42
41
  > The old `vault` asset type was removed in 0.9.0; use `env` for whole `.env`
43
42
  > groups and `secret` for standalone sensitive values.
44
43
 
package/SECURITY.md CHANGED
@@ -82,9 +82,11 @@ containing secrets or private notes.
82
82
 
83
83
  ## Known non-issues
84
84
 
85
- - **`akm` requires Bun, Node.js >= 20.12, or the prebuilt binary.** Older
86
- Node.js versions are unsupported because required runtime APIs are missing.
87
- This is a compatibility limitation, not a security risk.
85
+ - **The `akm-cli` npm package requires Node.js >= 20.12 as its bootstrap.** A
86
+ working Bun >= 1.0 is preferred for execution when it is also on `PATH`; old,
87
+ unusable, or absent Bun installations fall back to Node.js. Bun does not
88
+ remove the package's Node.js requirement. Standalone binaries are
89
+ runtime-free. This is a compatibility limitation, not a security risk.
88
90
  - **Workflows can read any file the akm process can read.** This is not a
89
91
  bug — see "Threat model" above.
90
92
  - **Installing `akm-cli` runs the preinstall hook.** The hook only validates
package/dist/akm CHANGED
@@ -1,38 +1,49 @@
1
- #!/bin/sh
2
- # This Source Code Form is subject to the terms of the Mozilla Public
3
- # License, v. 2.0. If a copy of the MPL was not distributed with this
4
- # file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
+ #!/usr/bin/env node
2
+ // This Source Code Form is subject to the terms of the Mozilla Public
3
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
5
 
6
- case "$0" in
7
- */*) SCRIPT_PATH=$0 ;;
8
- *)
9
- SCRIPT_PATH=$(command -v -- "$0" 2>/dev/null || true)
10
- if [ -z "$SCRIPT_PATH" ]; then
11
- echo "akm launcher could not resolve its own path." >&2
12
- exit 127
13
- fi
14
- ;;
15
- esac
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
16
8
 
17
- if command -v readlink >/dev/null 2>&1; then
18
- while [ -L "$SCRIPT_PATH" ]; do
19
- LINK_TARGET=$(readlink "$SCRIPT_PATH")
20
- case "$LINK_TARGET" in
21
- /*) SCRIPT_PATH=$LINK_TARGET ;;
22
- *) SCRIPT_PATH=${SCRIPT_PATH%/*}/$LINK_TARGET ;;
23
- esac
24
- done
25
- fi
9
+ if (!process.versions.bun) {
10
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
11
+ if (major < 20 || (major === 20 && minor < 12)) {
12
+ console.error("The akm-cli npm package requires Node.js >= 20.12 to bootstrap.");
13
+ process.exit(1);
14
+ }
15
+ }
26
16
 
27
- SCRIPT_DIR=$(CDPATH= cd -- "${SCRIPT_PATH%/*}" && pwd)
17
+ const bunProbe = process.versions.bun
18
+ ? { status: 0, stdout: process.versions.bun }
19
+ : spawnSync("bun", ["--version"], {
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ timeout: 5_000,
23
+ });
24
+ const [bunMajor = 0] = (bunProbe.status === 0 ? bunProbe.stdout.trim() : "").split(".").map(Number);
25
+ const useBun = bunMajor >= 1;
26
+ const bunEntry = fileURLToPath(new URL("./cli.js", import.meta.url));
27
+ const nodeEntry = fileURLToPath(new URL("./cli-node.mjs", import.meta.url));
28
28
 
29
- if command -v bun >/dev/null 2>&1; then
30
- exec bun "$SCRIPT_DIR/cli.js" "$@"
31
- fi
29
+ if (!useBun && !process.versions.bun) {
30
+ await import("./cli-node.mjs");
31
+ } else {
32
+ const command = useBun ? (process.versions.bun ? process.execPath : "bun") : "node";
33
+ const entry = useBun ? bunEntry : nodeEntry;
34
+ const runtime = useBun ? "Bun" : "Node.js";
35
+ const result = await new Promise((resolve) => {
36
+ const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
37
+ child.once("error", (error) => resolve({ error }));
38
+ child.once("exit", (code, signal) => resolve({ code, signal }));
39
+ });
32
40
 
33
- if command -v node >/dev/null 2>&1; then
34
- exec node "$SCRIPT_DIR/cli-node.mjs" "$@"
35
- fi
36
-
37
- echo "akm requires Bun or Node.js >= 20.12.0 on PATH." >&2
38
- exit 127
41
+ if (result.error) {
42
+ console.error(`akm launcher could not start ${runtime}: ${result.error.message}`);
43
+ process.exitCode = 127;
44
+ } else if (result.signal) {
45
+ process.kill(process.pid, result.signal);
46
+ } else {
47
+ process.exitCode = result.code ?? 1;
48
+ }
49
+ }
@@ -1,38 +1,49 @@
1
- #!/bin/sh
2
- # This Source Code Form is subject to the terms of the Mozilla Public
3
- # License, v. 2.0. If a copy of the MPL was not distributed with this
4
- # file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
+ #!/usr/bin/env node
2
+ // This Source Code Form is subject to the terms of the Mozilla Public
3
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
5
 
6
- case "$0" in
7
- */*) SCRIPT_PATH=$0 ;;
8
- *)
9
- SCRIPT_PATH=$(command -v -- "$0" 2>/dev/null || true)
10
- if [ -z "$SCRIPT_PATH" ]; then
11
- echo "akm-migrate-storage launcher could not resolve its own path." >&2
12
- exit 127
13
- fi
14
- ;;
15
- esac
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
16
8
 
17
- if command -v readlink >/dev/null 2>&1; then
18
- while [ -L "$SCRIPT_PATH" ]; do
19
- LINK_TARGET=$(readlink "$SCRIPT_PATH")
20
- case "$LINK_TARGET" in
21
- /*) SCRIPT_PATH=$LINK_TARGET ;;
22
- *) SCRIPT_PATH=${SCRIPT_PATH%/*}/$LINK_TARGET ;;
23
- esac
24
- done
25
- fi
9
+ if (!process.versions.bun) {
10
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
11
+ if (major < 20 || (major === 20 && minor < 12)) {
12
+ console.error("The akm-cli npm package requires Node.js >= 20.12 to bootstrap.");
13
+ process.exit(1);
14
+ }
15
+ }
26
16
 
27
- SCRIPT_DIR=$(CDPATH= cd -- "${SCRIPT_PATH%/*}" && pwd)
17
+ const bunProbe = process.versions.bun
18
+ ? { status: 0, stdout: process.versions.bun }
19
+ : spawnSync("bun", ["--version"], {
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ timeout: 5_000,
23
+ });
24
+ const [bunMajor = 0] = (bunProbe.status === 0 ? bunProbe.stdout.trim() : "").split(".").map(Number);
25
+ const useBun = bunMajor >= 1;
26
+ const bunEntry = fileURLToPath(new URL("./scripts/migrate-storage.js", import.meta.url));
27
+ const nodeEntry = fileURLToPath(new URL("./migrate-storage-node.mjs", import.meta.url));
28
28
 
29
- if command -v bun >/dev/null 2>&1; then
30
- exec bun "$SCRIPT_DIR/scripts/migrate-storage.js" "$@"
31
- fi
29
+ if (!useBun && !process.versions.bun) {
30
+ await import("./migrate-storage-node.mjs");
31
+ } else {
32
+ const command = useBun ? (process.versions.bun ? process.execPath : "bun") : "node";
33
+ const entry = useBun ? bunEntry : nodeEntry;
34
+ const runtime = useBun ? "Bun" : "Node.js";
35
+ const result = await new Promise((resolve) => {
36
+ const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
37
+ child.once("error", (error) => resolve({ error }));
38
+ child.once("exit", (code, signal) => resolve({ code, signal }));
39
+ });
32
40
 
33
- if command -v node >/dev/null 2>&1; then
34
- exec node "$SCRIPT_DIR/migrate-storage-node.mjs" "$@"
35
- fi
36
-
37
- echo "akm-migrate-storage requires Bun or Node.js >= 20.12.0 on PATH." >&2
38
- exit 127
41
+ if (result.error) {
42
+ console.error(`akm-migrate-storage launcher could not start ${runtime}: ${result.error.message}`);
43
+ process.exitCode = 127;
44
+ } else if (result.signal) {
45
+ process.kill(process.pid, result.signal);
46
+ } else {
47
+ process.exitCode = result.code ?? 1;
48
+ }
49
+ }
@@ -3,6 +3,8 @@
3
3
  <RegistrationInfo>
4
4
  <Description>akm scheduled task: {{TASK_ID}}</Description>
5
5
  <URI>{{FOLDER}}{{TASK_ID}}</URI>
6
+ <Source>{{SIGNATURE}}</Source>
7
+ <Documentation>Log target (informational only; schtasks doesn't redirect): {{LOG_PATH}}</Documentation>
6
8
  </RegistrationInfo>
7
9
  <Triggers>
8
10
  {{TRIGGER_XML}}
@@ -25,5 +27,4 @@
25
27
  <Arguments>{{ARGS}}</Arguments>
26
28
  </Exec>
27
29
  </Actions>
28
- <!-- Log target (informational only; schtasks doesn't redirect): {{LOG_PATH}} -->
29
30
  </Task>
@@ -67,6 +67,9 @@ Options:
67
67
  Only process refs with recent feedback signal events
68
68
  --min-retrieval-count <n>
69
69
  Retrieval fallback threshold when no recent feedback exists (default: 5)
70
+ --timeout-ms <n> Wall-clock budget for the entire live run (default: 7200000)
71
+ --skip-if-locked Exit 0 without doing work when another improve run owns
72
+ the whole-run lock; no triage, indexing, events, or sync
70
73
  --json-to-stdout Emit the full JSON result on stdout (legacy behaviour).
71
74
  (0.8.0+: full result is recorded in the improve_runs table of
72
75
  state.db and stdout is empty; use --json-to-stdout for the prior
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";
@@ -93,6 +93,7 @@ import { workflowCommand } from "./commands/workflow-cli.js";
93
93
  import { bestEffort } from "./core/best-effort.js";
94
94
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
95
95
  import { UsageError } from "./core/errors.js";
96
+ import { assertNoPendingMigrationOperation } from "./core/migration-operation.js";
96
97
  import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
97
98
  import { plainize } from "./core/tty.js";
98
99
  import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
@@ -541,14 +542,26 @@ export const main = defineCommand({
541
542
  },
542
543
  });
543
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
+ }
544
554
  /** Recovery/setup surfaces must remain reachable when config.json is invalid. */
545
555
  export function shouldBypassConfigStartup(argv) {
546
556
  const args = argv.slice(2);
547
557
  if (args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v"))
548
558
  return true;
549
- const command = findCittyTopLevelCommand([...args], MAIN_TOP_LEVEL_ARGS);
559
+ const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
560
+ const command = commandIndex >= 0 ? args[commandIndex] : undefined;
550
561
  if (command === "setup" || command === "backup" || command === "migrate")
551
562
  return true;
563
+ if (isTaskRunWithId(argv))
564
+ return true;
552
565
  if (command !== "config")
553
566
  return false;
554
567
  const configIndex = args.indexOf("config");
@@ -584,6 +597,8 @@ if (import.meta.main || process.env.AKM_NODE_ENTRY === "1") {
584
597
  // rather than letting the raw exception escape with a stack trace.
585
598
  try {
586
599
  applyEarlyStderrFlags(process.argv);
600
+ if (isTaskRunWithId(process.argv))
601
+ assertNoPendingMigrationOperation();
587
602
  const bypassConfig = shouldBypassConfigStartup(process.argv);
588
603
  initOutputMode(process.argv, bypassConfig ? (DEFAULT_CONFIG.output ?? {}) : (loadConfig().output ?? {}));
589
604
  }
@@ -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 = "—";
@@ -146,7 +146,7 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
146
146
  });
147
147
  const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
148
148
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
149
- const activeRows = taskRows.filter((row) => row.status === "active");
149
+ const activeRows = taskRows.filter((row) => row.status === "active" && row.completed_at === null);
150
150
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
151
151
  const promptRows = taskRows.filter((row) => row.target_kind === "prompt");
152
152
  const promptFailures = promptRows.filter((row) => {
@@ -94,7 +94,7 @@ export function akmHealth(options = {}) {
94
94
  const taskRows = queryTaskHistory(db, { since });
95
95
  const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
96
96
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
97
- const activeRows = taskRows.filter((row) => row.status === "active");
97
+ const activeRows = taskRows.filter((row) => row.status === "active" && row.completed_at === null);
98
98
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
99
99
  const promptRows = taskRows.filter((row) => row.target_kind === "prompt");
100
100
  const promptFailures = promptRows.filter((row) => {
@@ -83,7 +83,7 @@ export async function promoteMemoryToKnowledge(ctx) {
83
83
  const mergeResponse = await chat(ctx.llmConfig, [
84
84
  { role: "system", content: "Return only valid JSON. No prose." },
85
85
  { role: "user", content: mergePrompt },
86
- ]);
86
+ ], ctx.signal ? { signal: ctx.signal } : undefined);
87
87
  const mergeResult = parseEmbeddedJsonResponse(mergeResponse);
88
88
  if (mergeResult?.action === "NOOP") {
89
89
  // Existing content is authoritative — no update needed.
@@ -139,7 +139,11 @@ export async function promoteMemoryToKnowledge(ctx) {
139
139
  if (ctx.strategy?.processes?.distill?.qualityGate?.enabled ?? true) {
140
140
  // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
141
141
  const similarLessons = await fetchSimilarLessonsFn(resolvedPromotionContent.slice(0, 500), 3);
142
- const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined, ctx.llmConfig);
142
+ const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, {
143
+ ...(similarLessons.length > 0 ? { similarLessons } : {}),
144
+ ...(ctx.llmConfig ? { llmConfig: ctx.llmConfig } : {}),
145
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
146
+ });
143
147
  if (!judgeResult.pass) {
144
148
  if (judgeResult.reviewNeeded) {
145
149
  // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.