akm-cli 0.9.9 → 0.9.11

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 (33) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/STABILITY.md +22 -14
  3. package/dist/commands/health/checks.js +23 -7
  4. package/dist/commands/health/improve-metrics.js +12 -0
  5. package/dist/commands/improve/distill/quality-gate.js +13 -5
  6. package/dist/commands/improve/eval-cases.js +9 -2
  7. package/dist/commands/improve/improve.js +19 -4
  8. package/dist/commands/improve/loop-stages.js +13 -3
  9. package/dist/commands/tasks/tasks-cli.js +48 -1
  10. package/dist/commands/tasks/validate.js +186 -0
  11. package/dist/commands/url-checker.js +75 -16
  12. package/dist/core/bundle-id.js +7 -1
  13. package/dist/core/config/schema/engines.js +17 -0
  14. package/dist/core/improve-result.js +8 -0
  15. package/dist/core/paths.js +112 -0
  16. package/dist/indexer/search/search-source.js +3 -2
  17. package/dist/integrations/agent/engine-resolution.js +92 -3
  18. package/dist/integrations/agent/execution-lowering.js +15 -2
  19. package/dist/integrations/agent/runner-dispatch.js +16 -3
  20. package/dist/integrations/agent/runner.js +2 -0
  21. package/dist/output/shapes/passthrough.js +1 -0
  22. package/dist/scripts/akm-migrate-node.js +1043 -822
  23. package/dist/scripts/akm-migrate.js +1043 -822
  24. package/dist/tasks/scheduler-sync.js +51 -25
  25. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  26. package/docs/integration/bundling-akm.md +1 -1
  27. package/docs/migration/v0.8-to-v0.9.md +32 -0
  28. package/docs/reference/cli.md +32 -6
  29. package/docs/reference/configuration.md +12 -2
  30. package/docs/reference/data-and-telemetry.md +1 -1
  31. package/docs/reference/tasks.md +8 -0
  32. package/package.json +1 -1
  33. package/schemas/akm-config.json +8 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.9.11] - 2026-09-03
8
+
9
+ ### Added
10
+
11
+ - **`akm task validate <path>` reports what `akm task sync` would say about one
12
+ task file (#907).** It parses a single file by filesystem path, without a
13
+ bundle, a concept ref, or a configured engine, and reports `valid`,
14
+ `converts` (a v2/v3 source the migrator converts deterministically),
15
+ `blocked` (needs a human decision), `invalid`, or `not-a-task`, with the
16
+ reason sync would give. It runs the same two gates sync runs before
17
+ installing a schedule, the defaults-applied schedule input contract and
18
+ the cron dialect check, which are now shared with `compileTaskSources` so
19
+ the two commands cannot drift. Exit 0 for valid/converts, 1 for the rest,
20
+ 2 for a missing or unreadable path. `resolved` on success is the compiled
21
+ task shape (id, version, target, inputs, schedule), never an
22
+ execution-lowered plan.
23
+ - **`engines.<name>.apiKeyFile` supplies an LLM engine credential from a file
24
+ (#905).** A host that refuses secrets in the process environment can point
25
+ an engine at a path instead of a `$VAR` reference: `~` expands, one
26
+ trailing newline is trimmed, the file is read only at dispatch, and a
27
+ missing or empty file is a config error naming the engine and path but
28
+ never the value. Setting both `apiKey` and `apiKeyFile` is rejected;
29
+ setting neither still falls through to the implicit
30
+ `AKM_ENGINE_<NAME>_API_KEY` convention. The value is redacted from
31
+ dispatch output the same way an env-backed credential is, and `akm health`
32
+ checks the file is present and non-empty.
33
+
34
+ ### Changed
35
+
36
+ - **`akm improve`'s machine-local writers moved out of `$STASH/.akm` into
37
+ `$STATE` and `$CACHE` (#890).** `distill-rejected/`, `eval-cases/`,
38
+ `measurement/verdicts/`, the synthetic `unresolved-sources/` placeholder,
39
+ and the improve-pipeline lock files never met the "must travel with the
40
+ content" rule; they now live under `$STATE/improve/…/<stash>/`,
41
+ `$CACHE/index/unresolved-sources/<stash>/`, and `$STATE/locks/<stash>/`,
42
+ namespaced by a short hash of the resolved stash path. `akm migrate
43
+ status` and `apply` gain a relocation step that covers the default stash
44
+ and every other filesystem-backed bundle (remote sources are skipped
45
+ without a network call), moves files by rename or copy-then-delete, and
46
+ deletes an old lock only when the same staleness check `akm improve` uses
47
+ says its holder is dead; a live lock is left in place and reported. The
48
+ step is idempotent and `--dry-run` moves nothing. Scripts that read the
49
+ old paths should switch to the new locations named in
50
+ `docs/architecture/internals/storage-locations.md`.
51
+
52
+ ### Fixed
53
+
54
+ - **The dead-link check states its coverage and no longer lies about it
55
+ (#892).** `akm improve`'s post-loop check scanned at most ten knowledge
56
+ refs and fired every HEAD request at once with no timeout. It now scans
57
+ every actionable ref, checks at a bounded concurrency, bounds each request
58
+ at five seconds and reports a timeout as a dead entry, and counts a DNS or
59
+ connection failure as skipped rather than as dead or as fine. The result
60
+ carries `checked`, `total`, and `skipped`, threaded into the improve
61
+ result, the `improve_completed` event, and `akm health`'s improve summary.
62
+ The other constants the issue names (`MAX_URLS`, the per-entry slice,
63
+ `MAX_BODY_CHARS`, the duplicated `MAX_CONTENT_CHARS`) were already removed
64
+ in 0.9.8, and the curate score floors only decide whether a second search
65
+ pass runs; they never filter returned results.
66
+
67
+ ## [0.9.10] - 2026-09-02
68
+
69
+ ### Fixed
70
+
71
+ - **`akm task history <id>` now filters to that task instead of silently
72
+ ignoring the positional id (#911).** `task run <id>` and `task explain <ref>`
73
+ take the id positionally, so `task history <id>` was the natural thing to
74
+ write, and it answered with every task's newest rows while exiting 0. A
75
+ positional id now means the same as `--id`; passing both with different
76
+ values is a usage error (exit 2) rather than a silent pick.
77
+
7
78
  ## [0.9.9] - 2026-09-02
8
79
 
9
80
  ### Changed
package/STABILITY.md CHANGED
@@ -112,6 +112,7 @@ enumeration of the whole `proposal` noun group.
112
112
  | `akm task sync` | Evolving | |
113
113
  | `akm task doctor` | Evolving | |
114
114
  | `akm task explain` | Evolving | New in 0.9.2; secret-shaped values in provenance output are redacted on a best-effort heuristic basis (not a guarantee). |
115
+ | `akm task validate` | Evolving | New in 0.9.11; read-only, and the only `task` subcommand that takes a bare filesystem path instead of a ref — the file need not belong to any configured bundle. |
115
116
 
116
117
  ## Stable
117
118
 
@@ -256,21 +257,28 @@ CHANGELOG with a migration note.
256
257
  `akm improve && akm proposal drain --promote --yes`, or a `triage` block
257
258
  with `applyMode: "promote"` in your strategy.
258
259
  - **Tasks** — `akm task` subcommand surface (`add | run | sync | doctor |
259
- history | explain`; no alias, no `list`/`remove`/`init`/`enable`/`disable`);
260
- task source v4 YAML (typed `inputs:`, optional `schedule:`) is the only
261
- accepted version — task v3 and task v2 sources are converted by
262
- `akm migrate apply`. Command tasks use named engines and task history
263
- metadata is versioned. Schema additions in patch releases; removals only at
264
- minor. Bare `akm task` is a usage error naming the subcommands
265
- (`akm task doctor` reports scheduler diagnostics). `akm task explain <ref>`
266
- (new in 0.9.2) and `akm workflow plan <ref>` are both zero-write
267
- provenance surfaces: they show what a task or workflow would do —
268
- resolved target, input bindings, child expansion — without starting or
269
- publishing a run. `akm workflow plan` is secret-free **by construction**
270
- (the excluded data never reaches the command). `akm task explain`
271
- instead **redacts** secret-shaped input values on a best-effort
260
+ history | explain | validate`; no alias, no
261
+ `list`/`remove`/`init`/`enable`/`disable`); task source v4 YAML (typed
262
+ `inputs:`, optional `schedule:`) is the only accepted version — task v3 and
263
+ task v2 sources are converted by `akm migrate apply`. Command tasks use
264
+ named engines and task history metadata is versioned. Schema additions in
265
+ patch releases; removals only at minor. Bare `akm task` is a usage error
266
+ naming the subcommands (`akm task doctor` reports scheduler diagnostics).
267
+ `akm task explain <ref>` (new in 0.9.2) and `akm workflow plan <ref>` are
268
+ both zero-write provenance surfaces: they show what a task or workflow
269
+ would do — resolved target, input bindings, child expansion — without
270
+ starting or publishing a run. `akm workflow plan` is secret-free **by
271
+ construction** (the excluded data never reaches the command). `akm task
272
+ explain` instead **redacts** secret-shaped input values on a best-effort
272
273
  heuristic basis — a value that doesn't match the heuristic can still
273
- print unredacted.
274
+ print unredacted. `akm task validate <path>` (new in 0.9.11) is the same
275
+ kind of zero-write introspection as `explain`, but takes a bare filesystem
276
+ path rather than a bundle-qualified ref — it reports whether that ONE file
277
+ would parse cleanly (`valid`), auto-convert from task v2/v3 (`converts`),
278
+ need a human decision the deterministic migrator can't make (`blocked`),
279
+ fail schema validation (`invalid`), or isn't a task source at all
280
+ (`not-a-task`) — exactly the diagnostic `akm task sync` would produce for
281
+ it, before the file is ever wired into a bundle or the scheduler.
274
282
  - **Workflow plan** — `akm workflow plan <ref>`, new in 0.9.2: zero-write
275
283
  compile+freeze introspection (the canonical step graph, task/child
276
284
  expansion, input bindings, and lowering notices for a workflow, without
@@ -6,13 +6,20 @@ import { loadConfig } from "../../core/config/config.js";
6
6
  import { ConfigError } from "../../core/errors.js";
7
7
  import { listPendingStateMigrations } from "../../core/state-db.js";
8
8
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
9
- import { resolveEngine } from "../../integrations/agent/engine-resolution.js";
9
+ import { lookupApiKeyFileValue, resolveEngine } from "../../integrations/agent/engine-resolution.js";
10
10
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
11
11
  import { loadModelMap, mergeModelMapLayers, parseModelMapLayer, readInstalledModelMapText, resolveModelMapAlias, userModelMapPath, } from "../../integrations/agent/model-map.js";
12
12
  import { resolveImprovePlan } from "../improve/improve-strategies.js";
13
13
  import { ACTIVE_RUN_WARN_MS, TASK_FAIL_RATE_WARN } from "./types.js";
14
- function credentialAvailable(credential, env) {
15
- return !credential?.required || credential.names.some((name) => Boolean(env[name]?.trim()));
14
+ function credentialAvailable(credential, env, apiKeyFile) {
15
+ if (credential?.required)
16
+ return credential.names.some((name) => Boolean(env[name]?.trim()));
17
+ // #905: an engine with no env descriptor may still require a file-backed
18
+ // credential — probe it too, rather than reporting an unreadable/empty
19
+ // apiKeyFile as available just because it carries no env var names.
20
+ if (apiKeyFile !== undefined)
21
+ return lookupApiKeyFileValue(apiKeyFile) !== undefined;
22
+ return true;
16
23
  }
17
24
  function runConfiguredEngineProbe(checkName, engineName, config, deps) {
18
25
  if (!engineName) {
@@ -57,6 +64,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
57
64
  const fallbackEngine = configuredEngine.llmEngine ?? config.defaults?.llmEngine;
58
65
  let fallback;
59
66
  let fallbackCredential;
67
+ let fallbackApiKeyFile;
60
68
  let sdkRunner;
61
69
  const resolve = deps.resolveEngine ?? resolveEngine;
62
70
  try {
@@ -70,6 +78,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
70
78
  if (sdkRunner?.fallbackConnection && fallbackEngine) {
71
79
  fallback = { kind: "llm", engine: fallbackEngine, connection: sdkRunner.fallbackConnection };
72
80
  fallbackCredential = sdkRunner.fallbackCredential;
81
+ fallbackApiKeyFile = sdkRunner.fallbackApiKeyFile;
73
82
  }
74
83
  else if (fallbackEngine) {
75
84
  try {
@@ -77,6 +86,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
77
86
  if (resolved.kind === "llm") {
78
87
  fallback = resolved;
79
88
  fallbackCredential = resolved.credential;
89
+ fallbackApiKeyFile = resolved.apiKeyFile;
80
90
  }
81
91
  }
82
92
  catch {
@@ -85,7 +95,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
85
95
  }
86
96
  const configuredModel = configuredEngine.model;
87
97
  const effectiveModel = sdkRunner?.profile.model ?? configuredModel ?? fallback?.connection.model;
88
- const fallbackCredentialAvailable = credentialAvailable(fallbackCredential, env);
98
+ const fallbackCredentialAvailable = credentialAvailable(fallbackCredential, env, fallbackApiKeyFile);
89
99
  const missing = [
90
100
  !packageAvailable ? "@opencode-ai/sdk package" : undefined,
91
101
  !binaryAvailable ? `${binary} binary` : undefined,
@@ -121,7 +131,7 @@ function runConfiguredEngineProbe(checkName, engineName, config, deps) {
121
131
  try {
122
132
  const runner = (deps.resolveEngine ?? resolveEngine)(engineName, config);
123
133
  if (runner.kind === "llm") {
124
- const requiredCredentialAvailable = credentialAvailable(runner.credential, env);
134
+ const requiredCredentialAvailable = credentialAvailable(runner.credential, env, runner.apiKeyFile);
125
135
  return {
126
136
  name: checkName,
127
137
  kind: "deterministic",
@@ -287,7 +297,7 @@ export function runActiveImproveStrategyProbe(deps = {}) {
287
297
  const unavailableProcesses = Object.entries(plan.processes).flatMap(([name, process]) => {
288
298
  if (!process.enabled || !process.runner)
289
299
  return [];
290
- return credentialAvailable(process.runner.credential, env) ? [] : [name];
300
+ return credentialAvailable(process.runner.credential, env, process.runner.apiKeyFile) ? [] : [name];
291
301
  });
292
302
  if (plan.triageJudgment) {
293
303
  const judgmentCredential = plan.triageJudgment.kind === "llm"
@@ -295,8 +305,14 @@ export function runActiveImproveStrategyProbe(deps = {}) {
295
305
  : plan.triageJudgment.kind === "sdk"
296
306
  ? plan.triageJudgment.fallbackCredential
297
307
  : undefined;
298
- if (!credentialAvailable(judgmentCredential, env))
308
+ const judgmentApiKeyFile = plan.triageJudgment.kind === "llm"
309
+ ? plan.triageJudgment.apiKeyFile
310
+ : plan.triageJudgment.kind === "sdk"
311
+ ? plan.triageJudgment.fallbackApiKeyFile
312
+ : undefined;
313
+ if (!credentialAvailable(judgmentCredential, env, judgmentApiKeyFile)) {
299
314
  unavailableProcesses.push("triage.judgment");
315
+ }
300
316
  }
301
317
  return {
302
318
  name: "active-improve-strategy",
@@ -76,6 +76,9 @@ function createUnknownImproveMetrics() {
76
76
  coverageGapCount: 0,
77
77
  evalCasesWritten: 0,
78
78
  deadUrlCount: 0,
79
+ deadUrlsChecked: 0,
80
+ deadUrlsTotal: 0,
81
+ deadUrlsSkipped: 0,
79
82
  memorySummary: { eligible: 0, derived: 0 },
80
83
  memoryCleanup: {
81
84
  pruneCandidates: 0,
@@ -333,6 +336,12 @@ function applyMiscCounters(metrics, result) {
333
336
  metrics.evalCasesWritten += toFiniteNumber(result.evalCasesWritten);
334
337
  if (Array.isArray(result.deadUrls))
335
338
  metrics.deadUrlCount += result.deadUrls.length;
339
+ const deadUrlCoverage = result.deadUrlCoverage;
340
+ if (deadUrlCoverage && typeof deadUrlCoverage === "object") {
341
+ metrics.deadUrlsChecked += toFiniteNumber(deadUrlCoverage.checked);
342
+ metrics.deadUrlsTotal += toFiniteNumber(deadUrlCoverage.total);
343
+ metrics.deadUrlsSkipped += toFiniteNumber(deadUrlCoverage.skipped);
344
+ }
336
345
  }
337
346
  function applyMemorySummary(metrics, result) {
338
347
  const memorySummary = result.memorySummary;
@@ -568,6 +577,9 @@ function mergeImproveMetrics(dst, src) {
568
577
  dst.coverageGapCount += src.coverageGapCount;
569
578
  dst.evalCasesWritten += src.evalCasesWritten;
570
579
  dst.deadUrlCount += src.deadUrlCount;
580
+ dst.deadUrlsChecked += src.deadUrlsChecked;
581
+ dst.deadUrlsTotal += src.deadUrlsTotal;
582
+ dst.deadUrlsSkipped += src.deadUrlsSkipped;
571
583
  // NOTE: memorySummary (derived/eligible) is a WHOLE-STASH snapshot recorded on
572
584
  // every run, NOT a per-run increment — summing it across the window inflates
573
585
  // it ~N× (the 1.2M-eligible bug). It is set from the most recent run in
@@ -15,6 +15,7 @@ import { timestampForFilename } from "../../../core/common.js";
15
15
  import { ConfigError } from "../../../core/errors.js";
16
16
  import { appendEvent } from "../../../core/events.js";
17
17
  import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
18
+ import { getDistillRejectedDir } from "../../../core/paths.js";
18
19
  import { withStateDb } from "../../../core/state-db.js";
19
20
  import { recordWrittenPath } from "../../../core/write-provenance.js";
20
21
  import { callStructured } from "../../../llm/structured-call.js";
@@ -242,8 +243,9 @@ export async function runReflectQualityJudge(config, candidateContent, sourceCon
242
243
  }
243
244
  // ── Quality-rejection helper ─────────────────────────────────────────────────
244
245
  /**
245
- * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
246
- * quality-rejected event, and return the `quality_rejected` envelope.
246
+ * Write a rejected lesson to `$STATE/improve/distill-rejected/<stash>/`
247
+ * (itlackey/akm#890), append a `distill_invoked` quality-rejected event, and
248
+ * return the `quality_rejected` envelope.
247
249
  *
248
250
  * @param stash - Root stash directory.
249
251
  * @param inputRef - The original input ref (for the event).
@@ -257,13 +259,19 @@ export async function runReflectQualityJudge(config, candidateContent, sourceCon
257
259
  export function writeQualityRejection(stash, inputRef, proposalRef, content, score, reason, extraMeta = {}, eligibilitySource, eventsCtx) {
258
260
  // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
259
261
  const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
260
- const rejectDir = path.join(stash, ".akm", "distill-rejected");
262
+ const rejectDir = getDistillRejectedDir(stash);
261
263
  fs.mkdirSync(rejectDir, { recursive: true });
262
264
  const ts = timestampForFilename();
263
265
  const rejectPath = path.join(rejectDir, `${ts}-${proposalRef.replace(/[:/\\]/g, "-")}.md`);
264
266
  fs.writeFileSync(rejectPath, `---\nscore: ${score}\nreason: ${reason}\noutcome: ${outcome}\n---\n\n${content}`, "utf8");
265
- // #652: the rejection envelope lands under the managed `.akm/` tree, which
266
- // the pre-provenance sync swept up by pathspec journal it explicitly.
267
+ // #652 / itlackey/akm#890: journal it even though it now lands under
268
+ // `$STATE`, outside the stash's git repo`result.writtenPaths` reports
269
+ // every path a run touched, in or out of the stash (describeRunWrittenPaths
270
+ // in improve.ts falls back to the absolute path for anything outside the
271
+ // stash root), and the auto-sync commit's own containment check
272
+ // (resolveSyncPathSet's `relativeWrittenPath`) already drops anything
273
+ // outside `repoDir` from what gets staged — recording it here cannot cause
274
+ // it to be committed.
267
275
  recordWrittenPath(rejectPath);
268
276
  appendEvent({
269
277
  eventType: "distill_invoked",
@@ -4,9 +4,10 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { writeFileAtomic } from "../../core/common.js";
7
+ import { getEvalCasesDir } from "../../core/paths.js";
7
8
  import { recordWrittenPath } from "../../core/write-provenance.js";
8
9
  export function writeEvalCase(stashDir, evalCase) {
9
- const evalDir = path.join(stashDir, ".akm", "eval-cases");
10
+ const evalDir = getEvalCasesDir(stashDir);
10
11
  fs.mkdirSync(evalDir, { recursive: true });
11
12
  const fileName = `${evalCase.slug}.md`;
12
13
  const filePath = path.join(evalDir, fileName);
@@ -29,11 +30,17 @@ Use it as a regression test: future improve runs on this ref should not produce
29
30
  output that would be rejected for the same reason.
30
31
  `;
31
32
  writeFileAtomic(filePath, content);
33
+ // itlackey/akm#890: journal it even though it now lands under `$STATE`,
34
+ // outside the stash's git repo — `result.writtenPaths` still reports it
35
+ // (as an absolute path; see describeRunWrittenPaths in improve.ts), and
36
+ // the auto-sync commit's own containment check already excludes anything
37
+ // outside the stash from what gets staged, so recording it here cannot
38
+ // cause it to be committed.
32
39
  recordWrittenPath(filePath);
33
40
  return filePath;
34
41
  }
35
42
  export function countEvalCases(stashDir) {
36
- const evalDir = path.join(stashDir, ".akm", "eval-cases");
43
+ const evalDir = getEvalCasesDir(stashDir);
37
44
  if (!fs.existsSync(evalDir))
38
45
  return 0;
39
46
  try {
@@ -11,7 +11,7 @@ import { ConfigError, rethrowIfTestIsolationError, UsageError } from "../../core
11
11
  import { appendEvent, readEvents } from "../../core/events.js";
12
12
  import { classifyImproveAction, foldDistillSkipped } from "../../core/improve-types.js";
13
13
  import { resolveMutationTarget } from "../../core/mutation-target.js";
14
- import { getDbPath, getStateDbPathInDataDir } from "../../core/paths.js";
14
+ import { getDbPath, getStashLocksDir, getStateDbPathInDataDir } from "../../core/paths.js";
15
15
  import { redactSensitiveText } from "../../core/redaction.js";
16
16
  import { openStateDatabase } from "../../core/state-db.js";
17
17
  import { info, warn, warnVerbose } from "../../core/warn.js";
@@ -481,8 +481,11 @@ function resolveImproveRunSetup(options) {
481
481
  // calling test's own at this point; we capture it before yielding the loop.
482
482
  const resolvedStateDbPath = getStateDbPathInDataDir();
483
483
  // One conservative run lock protects the complete mutation window, including
484
- // triage, indexing, proposal work, maintenance, and final stash sync.
485
- const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
484
+ // triage, indexing, proposal work, maintenance, and final stash sync. Moved
485
+ // out of `$STASH/.akm` to `$STATE/locks/<stash>/` (itlackey/akm#890): a lock
486
+ // file is machine-local coordination state, not content that must travel
487
+ // with the bundle.
488
+ const lockBaseDir = getStashLocksDir(primaryStashDir ?? options.stashDir ?? ".");
486
489
  const resolvedLockPath = improveLockPath(lockBaseDir);
487
490
  const effectiveSync = { ...improveProfile.sync, ...options.sync };
488
491
  return {
@@ -1146,6 +1149,7 @@ async function runImproveStageSequence(args) {
1146
1149
  // Concatenated arrays.
1147
1150
  const allWarnings = [];
1148
1151
  let deadUrls;
1152
+ let deadUrlCoverage;
1149
1153
  const finalActions = [];
1150
1154
  {
1151
1155
  const runPreparation = () => runImprovePreparationStageImpl({
@@ -1228,6 +1232,9 @@ async function runImproveStageSequence(args) {
1228
1232
  if (postLoopResult.deadUrls !== undefined) {
1229
1233
  deadUrls = [...(deadUrls ?? []), ...postLoopResult.deadUrls];
1230
1234
  }
1235
+ if (postLoopResult.deadUrlCoverage !== undefined) {
1236
+ deadUrlCoverage = postLoopResult.deadUrlCoverage;
1237
+ }
1231
1238
  const maintenanceActions = postLoopResult.maintenanceActions;
1232
1239
  if (maintenanceActions && maintenanceActions.length > 0) {
1233
1240
  finalActions.push(...preparation.actions, ...maintenanceActions);
@@ -1250,6 +1257,7 @@ async function runImproveStageSequence(args) {
1250
1257
  proposalsExpired,
1251
1258
  allWarnings,
1252
1259
  deadUrls,
1260
+ deadUrlCoverage,
1253
1261
  finalActions,
1254
1262
  };
1255
1263
  }
@@ -1261,7 +1269,7 @@ async function runImproveStageSequence(args) {
1261
1269
  function finalizeImproveResult(args) {
1262
1270
  const { guidance, memorySummary, memoryCleanupPlan, strategyFilteredRefs, rawPlannedRefs, indexSnapshot, triageDrain, eventsCtx, } = args;
1263
1271
  const { selectedStrategy, scope, options, primaryStashDir, startMs } = args.run;
1264
- const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, finalActions, } = args.seq;
1272
+ const { preparation, consolidation, memoryInference, graphExtraction, cycleMetrics, reflectsWithErrorContext, memoryInferenceDurationMs, graphExtractionDurationMs, orphansPurged, proposalsExpired, allWarnings, deadUrls, deadUrlCoverage, finalActions, } = args.seq;
1265
1273
  // C1 (13-bus-factor): fold the per-ref `distill-skipped` rows (~13k/run,
1266
1274
  // ~91% of result_json bytes) into a bounded aggregate BEFORE persistence.
1267
1275
  // The metric total + per-reason breakdown are preserved on `distillSkipped`;
@@ -1326,6 +1334,10 @@ function finalizeImproveResult(args) {
1326
1334
  ...(preparation.extract && preparation.extract.length > 0 ? { extract: preparation.extract } : {}),
1327
1335
  ...(primaryStashDir !== undefined ? { evalCasesWritten: countEvalCases(primaryStashDir) } : {}),
1328
1336
  ...(deadUrls !== undefined && deadUrls.length > 0 ? { deadUrls } : {}),
1337
+ // Present whenever the check ran, unlike `deadUrls` above — a clean run
1338
+ // (zero dead links) still needs to tell the health report how much of
1339
+ // the bundle it actually covered (#892).
1340
+ ...(deadUrlCoverage !== undefined ? { deadUrlCoverage } : {}),
1329
1341
  ...(reflectsWithErrorContext > 0 ? { reflectsWithErrorContext } : {}),
1330
1342
  ...(memoryInference ? { memoryInference } : {}),
1331
1343
  ...(graphExtraction ? { graphExtraction } : {}),
@@ -1501,6 +1513,9 @@ function emitImproveCompletedEvent(result, durations, eventsCtx) {
1501
1513
  coverageGapCount: result.coverageGaps?.length ?? 0,
1502
1514
  evalCasesWritten: result.evalCasesWritten ?? 0,
1503
1515
  deadUrlCount: result.deadUrls?.length ?? 0,
1516
+ deadUrlsChecked: result.deadUrlCoverage?.checked ?? 0,
1517
+ deadUrlsTotal: result.deadUrlCoverage?.total ?? 0,
1518
+ deadUrlsSkipped: result.deadUrlCoverage?.skipped ?? 0,
1504
1519
  memoryEligible: result.memorySummary.eligible,
1505
1520
  memoryDerived: result.memorySummary.derived,
1506
1521
  memoryCleanupPruneCandidates: result.memoryCleanup?.pruneCandidates.length ?? 0,
@@ -569,8 +569,16 @@ export async function runImprovePostLoopStage(args) {
569
569
  resolvedPlan,
570
570
  });
571
571
  let deadUrls;
572
+ let deadUrlCoverage;
572
573
  if (scope.mode === "all" && primaryStashDir && actionableRefs.length > 0) {
573
574
  try {
575
+ // Every actionable knowledge ref is scanned for URLs — there used to be
576
+ // a `.slice(0, 10)` here, capping the scan to the first ten refs while
577
+ // `deadUrlCoverage.total` counted only those, so a real bundle reported
578
+ // checked === total while most refs were never looked at (#892). URL
579
+ // extraction (a regex over already-loaded text) is cheap; it is the
580
+ // network requests that are expensive, and those are bounded by
581
+ // `checkDeadUrls`'s concurrency limit, not by trimming what gets scanned.
574
582
  const knowledgeEntries = actionableRefs
575
583
  .filter((r) => {
576
584
  try {
@@ -580,7 +588,6 @@ export async function runImprovePostLoopStage(args) {
580
588
  return false;
581
589
  }
582
590
  })
583
- .slice(0, 10)
584
591
  .map((r) => {
585
592
  // The URL scan needs the document body; filePath is pre-resolved on
586
593
  // eligible refs at planning time (#591). Best-effort — an unreadable
@@ -598,8 +605,10 @@ export async function runImprovePostLoopStage(args) {
598
605
  });
599
606
  if (knowledgeEntries.length > 0) {
600
607
  info(`[improve] checking URLs in ${knowledgeEntries.length} knowledge refs`);
601
- deadUrls = await checkDeadUrls(primaryStashDir, knowledgeEntries);
602
- info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs)`);
608
+ const urlCheck = await checkDeadUrls(primaryStashDir, knowledgeEntries);
609
+ deadUrls = urlCheck.deadUrls;
610
+ deadUrlCoverage = urlCheck.coverage;
611
+ info(`[improve] URL check complete (${deadUrls.length} dead/timeout URLs; checked ${urlCheck.coverage.checked} of ${urlCheck.coverage.total})`);
603
612
  }
604
613
  }
605
614
  catch {
@@ -625,6 +634,7 @@ export async function runImprovePostLoopStage(args) {
625
634
  return {
626
635
  allWarnings,
627
636
  deadUrls,
637
+ ...(deadUrlCoverage ? { deadUrlCoverage } : {}),
628
638
  ...(cycleMetrics ? { cycleMetrics } : {}),
629
639
  ...(maintenanceResult.memoryInference ? { memoryInference: maintenanceResult.memoryInference } : {}),
630
640
  ...(maintenanceResult.graphExtraction ? { graphExtraction: maintenanceResult.graphExtraction } : {}),
@@ -32,6 +32,7 @@ import { UsageError } from "../../core/errors.js";
32
32
  import { TASK_RUN_BOOLEAN_FLAGS, TASK_RUN_VALUE_FLAGS } from "../../tasks/task-run-reserved-flags.js";
33
33
  import { akmTaskExplain } from "./explain.js";
34
34
  import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksPrune, akmTasksRun, akmTasksSync, akmTasksSyncPlan, } from "./tasks.js";
35
+ import { akmTaskValidate } from "./validate.js";
35
36
  /** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
36
37
  const bundleArg = {
37
38
  bundle: {
@@ -289,9 +290,23 @@ const tasksRunCommand = defineCommand({
289
290
  });
290
291
  },
291
292
  });
293
+ /**
294
+ * #911: `task run <id>` and `task explain <ref>` take the id positionally, so
295
+ * `task history <id>` is the natural thing to write — and it used to be
296
+ * accepted and silently discarded, answering with every task's newest rows.
297
+ * A positional id now means the same as `--id`; giving both with different
298
+ * values is a usage error rather than a silent pick.
299
+ */
300
+ export function resolveTaskHistoryId(positional, flag) {
301
+ if (positional !== undefined && flag !== undefined && positional !== flag) {
302
+ throw new UsageError(`\`akm task history\` was given two task ids: "${positional}" and --id "${flag}". Pass one.`, "INVALID_FLAG_VALUE");
303
+ }
304
+ return flag ?? positional;
305
+ }
292
306
  const tasksHistoryCommand = defineJsonCommand({
293
307
  meta: { name: "history", description: "Show recent task run history" },
294
308
  args: {
309
+ task: { type: "positional", description: "Task id to filter to (same as --id)", required: false },
295
310
  id: { type: "string", description: "Filter to one task id" },
296
311
  limit: { type: "string", description: "Maximum rows to return (default 50)" },
297
312
  ...bundleArg,
@@ -299,7 +314,8 @@ const tasksHistoryCommand = defineJsonCommand({
299
314
  async run({ args }) {
300
315
  rejectRetiredTaskTargetFlag();
301
316
  const limit = parsePositiveIntFlag(args.limit ?? undefined);
302
- const result = await akmTasksHistory({ id: args.id, limit, target: args.bundle });
317
+ const id = resolveTaskHistoryId(args.task, args.id);
318
+ const result = await akmTasksHistory({ id, limit, target: args.bundle });
303
319
  output("task-history", result);
304
320
  },
305
321
  });
@@ -399,6 +415,36 @@ const tasksDoctorCommand = defineJsonCommand({
399
415
  output("task-doctor", result);
400
416
  },
401
417
  });
418
+ /**
419
+ * #907: `akm task validate`'s exit-code contract — `valid`/`converts` are
420
+ * successful outcomes (exit 0); `blocked`/`invalid`/`not-a-task` are
421
+ * diagnosed defects the caller must act on (exit 1, mirroring `task sync`'s
422
+ * own `failures.length > 0 -> EXIT_CODES.GENERAL`). A missing path or an
423
+ * unreadable file never reaches this function at all — `akmTaskValidate`
424
+ * throws a `UsageError` for those, which `defineJsonCommand`'s wrapping
425
+ * already maps to exit 2.
426
+ */
427
+ export function taskValidateExitCode(result) {
428
+ return result.outcome === "valid" || result.outcome === "converts" ? undefined : EXIT_CODES.GENERAL;
429
+ }
430
+ const tasksValidateCommand = defineJsonCommand({
431
+ meta: {
432
+ name: "validate",
433
+ description: "Parse a single task file by filesystem path — not a concept ref, and the file need not live in a " +
434
+ "configured bundle — and report the same diagnostic `akm task sync` would produce for it. Read-only; " +
435
+ "never touches the scheduler.",
436
+ },
437
+ args: {
438
+ path: { type: "positional", description: "Filesystem path to a task source YAML file", required: true },
439
+ },
440
+ async run({ args }) {
441
+ const result = await akmTaskValidate(args.path);
442
+ output("task-validate", result);
443
+ const exitCode = taskValidateExitCode(result);
444
+ if (exitCode !== undefined)
445
+ process.exitCode = exitCode;
446
+ },
447
+ });
402
448
  /**
403
449
  * #851: `akm task prune`'s exit-code contract mirrors `task sync --dry-run`'s
404
450
  * (`taskSyncDryRunExitCode` above) — non-zero whenever the preview lists
@@ -451,6 +497,7 @@ export const taskCommand = defineGroupCommand({
451
497
  add: tasksAddCommand,
452
498
  run: tasksRunCommand,
453
499
  explain: tasksExplainCommand,
500
+ validate: tasksValidateCommand,
454
501
  history: tasksHistoryCommand,
455
502
  sync: tasksSyncCommand,
456
503
  prune: tasksPruneCommand,