akm-cli 0.9.0-rc.3 → 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 (35) hide show
  1. package/CHANGELOG.md +6 -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/cli.js +25 -10
  8. package/dist/commands/health/html-report.js +23 -2
  9. package/dist/commands/health/improve-metrics.js +45 -6
  10. package/dist/commands/health/md-report.js +10 -1
  11. package/dist/commands/health/windows.js +1 -1
  12. package/dist/commands/health.js +1 -1
  13. package/dist/commands/tasks/default-tasks.js +5 -5
  14. package/dist/commands/tasks/tasks-cli.js +7 -3
  15. package/dist/commands/tasks/tasks.js +261 -68
  16. package/dist/core/improve-result.js +87 -7
  17. package/dist/core/state-db.js +1 -0
  18. package/dist/scripts/migrate-storage.js +21 -3
  19. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
  20. package/dist/storage/repositories/task-history-repository.js +30 -3
  21. package/dist/tasks/backends/cron.js +71 -38
  22. package/dist/tasks/backends/exec-utils.js +55 -3
  23. package/dist/tasks/backends/launchd.js +241 -50
  24. package/dist/tasks/backends/schtasks.js +434 -59
  25. package/dist/tasks/command-executable.js +93 -0
  26. package/dist/tasks/parser.js +142 -6
  27. package/dist/tasks/resolve-akm-bin.js +19 -4
  28. package/dist/tasks/runner.js +258 -93
  29. package/dist/tasks/schedule.js +108 -19
  30. package/dist/tasks/scheduler-invocation.js +86 -0
  31. package/dist/tasks/task-id.js +37 -0
  32. package/docs/README.md +103 -0
  33. package/docs/migration/release-notes/0.9.0.md +4 -2
  34. package/docs/migration/v0.8-to-v0.9.md +64 -9
  35. package/package.json +2 -3
package/CHANGELOG.md CHANGED
@@ -1234,14 +1234,16 @@ proposal and log storage, `--format html` output, and per-stage LLM telemetry.
1234
1234
 
1235
1235
  ### Added
1236
1236
 
1237
- - **Cross-runtime: akm now runs on Node.js (≥ 20) in addition to Bun** (#560,
1237
+ - **Cross-runtime: akm now runs on Node.js >= 20.12 in addition to Bun** (#560,
1238
1238
  #465). A two-file runtime boundary (`src/storage/database.ts` owns SQLite via
1239
1239
  `bun:sqlite` on Bun / `better-sqlite3` on Node; `src/runtime.ts` owns every
1240
1240
  `Bun.*` API) contains all runtime-specific code, enforced by a lint guard so it
1241
1241
  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.
1242
+ 20 and 22. The prompts dependency (`@clack/core`) uses `node:util.styleText`,
1243
+ added in Node 20.12; Node 18 is EOL and unsupported. The npm package uses Node
1244
+ as its bootstrap and prefers a working Bun >= 1.0 for execution when both are
1245
+ available. Old, unusable, or absent Bun installations fall back to Node.js;
1246
+ standalone binaries remain runtime-free.
1245
1247
  - **`session` asset type — agent sessions are now searchable** (#561). The
1246
1248
  `extract` pass, after distilling memory proposals from a session, additionally
1247
1249
  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>
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) => {
@@ -67,12 +67,12 @@ export const DEFAULT_IMPROVE_TASKS = [
67
67
  },
68
68
  ];
69
69
  /**
70
- * A schedule for the manual catch-up task. The scheduler requires a valid
71
- * cron expression even when the task is registered disabled, so we give the
72
- * unscheduled task a nominal far-future-ish cadence and leave it disabled
73
- * the documented entry point is `akm tasks run akm-improve-catchup`.
70
+ * A schedule for the manual catch-up task. The scheduler requires a portable
71
+ * expression even when the task is registered disabled, so we give it a
72
+ * nominal daily cadence and leave it disabled. The documented entry point is
73
+ * `akm tasks run akm-improve-catchup`.
74
74
  */
75
- const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 1 1 *"; // 04:00 on Jan 1 — never effectively fires
75
+ const MANUAL_TASK_NOMINAL_SCHEDULE = "0 4 * * *";
76
76
  const DEFAULT_DEPS = {
77
77
  list: akmTasksList,
78
78
  add: akmTasksAdd,
@@ -136,11 +136,15 @@ const tasksRunCommand = defineCommand({
136
136
  name: "run",
137
137
  description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
138
138
  },
139
- args: { id: { type: "positional", description: "Task id", required: true } },
139
+ args: {
140
+ id: { type: "positional", description: "Task id", required: true },
141
+ scheduled: { type: "boolean", description: "Internal marker for scheduler-generated runs", default: false },
142
+ },
140
143
  async run({ args }) {
141
144
  await runWithJsonErrors(async () => {
142
- const { id } = parseTaskRef(args.id);
143
- const envelope = await akmTasksRun(id);
145
+ const envelope = await akmTasksRun(args.id, {
146
+ scheduled: args.scheduled === true,
147
+ });
144
148
  output("tasks-run", envelope);
145
149
  if (envelope.exitCode !== 0)
146
150
  process.exit(envelope.exitCode);