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.
- package/CHANGELOG.md +71 -0
- package/STABILITY.md +22 -14
- package/dist/commands/health/checks.js +23 -7
- package/dist/commands/health/improve-metrics.js +12 -0
- package/dist/commands/improve/distill/quality-gate.js +13 -5
- package/dist/commands/improve/eval-cases.js +9 -2
- package/dist/commands/improve/improve.js +19 -4
- package/dist/commands/improve/loop-stages.js +13 -3
- package/dist/commands/tasks/tasks-cli.js +48 -1
- package/dist/commands/tasks/validate.js +186 -0
- package/dist/commands/url-checker.js +75 -16
- package/dist/core/bundle-id.js +7 -1
- package/dist/core/config/schema/engines.js +17 -0
- package/dist/core/improve-result.js +8 -0
- package/dist/core/paths.js +112 -0
- package/dist/indexer/search/search-source.js +3 -2
- package/dist/integrations/agent/engine-resolution.js +92 -3
- package/dist/integrations/agent/execution-lowering.js +15 -2
- package/dist/integrations/agent/runner-dispatch.js +16 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/scripts/akm-migrate-node.js +1043 -822
- package/dist/scripts/akm-migrate.js +1043 -822
- package/dist/tasks/scheduler-sync.js +51 -25
- package/dist/workflows/exec/dispatch-redaction.js +21 -7
- package/docs/integration/bundling-akm.md +1 -1
- package/docs/migration/v0.8-to-v0.9.md +32 -0
- package/docs/reference/cli.md +32 -6
- package/docs/reference/configuration.md +12 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/docs/reference/tasks.md +8 -0
- package/package.json +1 -1
- 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
|
|
260
|
-
task source v4 YAML (typed
|
|
261
|
-
accepted version — task v3 and
|
|
262
|
-
`akm migrate apply`. Command tasks use
|
|
263
|
-
metadata is versioned. Schema additions in
|
|
264
|
-
minor. Bare `akm task` is a usage error
|
|
265
|
-
(`akm task doctor` reports scheduler diagnostics).
|
|
266
|
-
(new in 0.9.2) and `akm workflow plan <ref>` are
|
|
267
|
-
provenance surfaces: they show what a task or workflow
|
|
268
|
-
resolved target, input bindings, child expansion — without
|
|
269
|
-
publishing a run. `akm workflow plan` is secret-free **by
|
|
270
|
-
(the excluded data never reaches the command). `akm task
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
* quality-rejected event, and
|
|
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 =
|
|
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
|
|
266
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
|
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,
|