akm-cli 0.9.3 → 0.9.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.
- package/CHANGELOG.md +73 -1
- package/README.md +1 -1
- package/SECURITY.md +1 -1
- package/STABILITY.md +1 -1
- package/dist/akm +2 -2
- package/dist/akm-migrate +2 -2
- package/dist/cli.js +5 -5
- package/dist/commands/health/improve-metrics.js +17 -0
- package/dist/commands/health/windows.js +2 -2
- package/dist/commands/health.js +2 -2
- package/dist/commands/improve/preparation.js +8 -1
- package/dist/commands/lint/index.js +3 -7
- package/dist/commands/tasks/tasks-cli.js +28 -3
- package/dist/commands/tasks/tasks.js +29 -1
- package/dist/core/adapter/adapters/akm-adapter.js +21 -14
- package/dist/core/adapter/adapters/akm-lint.js +3 -2
- package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
- package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
- package/dist/core/adapter/recognize-match.js +1 -20
- package/dist/core/asset/asset-placement.js +21 -2
- package/dist/core/common.js +21 -1
- package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
- package/dist/indexer/search/db-search.js +6 -0
- package/dist/indexer/walk/matchers.js +0 -22
- package/dist/output/shapes/helpers.js +19 -1
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +4 -0
- package/dist/registry/pinned-request-helper.js +2 -2
- package/dist/registry/pinned-transport.js +6 -6
- package/dist/scripts/akm-migrate-node.js +12624 -12555
- package/dist/scripts/akm-migrate.js +12624 -12555
- package/dist/storage/repositories/proposals-repository.js +33 -1
- package/dist/storage/repositories/task-history-repository.js +22 -10
- package/dist/tasks/run/task-history.js +23 -3
- package/dist/tasks/scheduler-sync-preview.js +43 -0
- package/dist/tasks/scheduler-sync.js +1 -0
- package/dist/tasks/source/bounded-document.js +1 -1
- package/dist/tasks/source/parse-task-source.js +77 -11
- package/dist/tasks/source/task-source-v3-frozen.js +428 -0
- package/dist/tasks/source/task-to-v3.js +500 -0
- package/dist/tasks/source/task-to-v4.js +467 -0
- package/docs/reference/cli.md +8 -3
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,79 @@ 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
|
-
## [
|
|
7
|
+
## [0.9.4] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Node.js 22 support is restored for the npm package.** 0.9.3 raised the
|
|
12
|
+
npm bootstrap floor to Node >= 24 as a policy simplification; no code in
|
|
13
|
+
the package actually requires a Node-24-only API, and the pinned
|
|
14
|
+
better-sqlite3 12.11.1 ships a prebuilt binary for Node 22 (ABI 127), so
|
|
15
|
+
nothing compiles from source. The floor returns to Node >= 22 across the
|
|
16
|
+
preinstall check, the CLI bootstrap guard, and the pinned-registry
|
|
17
|
+
helper, and the CI node-smoke matrix again runs BOTH Node 22 and 24 so
|
|
18
|
+
the supported floor is tested on every run, not merely declared.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`akm task sync --dry-run` previews the reconcile without touching the
|
|
23
|
+
scheduler (#849).** Prints the planned adds/updates/removes — removals
|
|
24
|
+
now carry their owning bundle — makes zero scheduler writes, and exits
|
|
25
|
+
non-zero when removals are pending so scripts can gate on it. The plan
|
|
26
|
+
renderer is shared, ready for the planned `task prune` (#851) to reuse.
|
|
27
|
+
- **Search hits now report which stage of the progressive AND->OR lexical
|
|
28
|
+
ladder produced them (#856).** `akm search` already ran strict AND, then
|
|
29
|
+
prefix AND, then an OR/prefix-OR recovery, stopping at the first stage
|
|
30
|
+
that returned candidates — but callers had no way to tell a strict match
|
|
31
|
+
from a heavily relaxed one. Local bundle hits now carry an optional
|
|
32
|
+
`matchStage: "exact" | "prefix" | "relaxed"` field (omitted for hits with
|
|
33
|
+
no FTS component, e.g. a pure-semantic hybrid contribution), surfaced at
|
|
34
|
+
`--detail normal`, `--detail full`, and `--shape agent` across all output
|
|
35
|
+
formats. Purely additive; no `schemaVersion` bump.
|
|
36
|
+
- **Previous-release corpus test.** New
|
|
37
|
+
`tests/integration/previous-release-corpus.test.ts` holds fixtures of
|
|
38
|
+
data shapes prior releases actually wrote (task v2/v3 sources, pre-#858
|
|
39
|
+
proposal rows) and asserts the current CLI reads or auto-handles every
|
|
40
|
+
one. Policy: every schema bump must add the old shape here — this suite
|
|
41
|
+
failing means an upgrade break was about to ship.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **`akm show`, `task run`/`explain`, `workflow run`/`plan`, and
|
|
46
|
+
`command run` no longer fail on bundles past 16,384 files (#857).**
|
|
47
|
+
Single-ref owner resolution walked the entire bundle tree on every
|
|
48
|
+
lookup, aborting with `file limit 16384 exceeded` once a bundle grew past
|
|
49
|
+
the cap — normal `improve` output accumulation was enough to get there.
|
|
50
|
+
Path-to-conceptId derivation is deterministic, so its inverse is now
|
|
51
|
+
computed in closed form: each adapter's `readCandidates` enumerates every
|
|
52
|
+
physical spelling that could own a conceptId (canonical placement, loose
|
|
53
|
+
off-canonical fallback, env `.env` duality) and the existing
|
|
54
|
+
verification/collision pipeline runs over that fixed set. The tree walk,
|
|
55
|
+
both scan caps, and `AdapterConceptScanError` are gone — no cap is needed
|
|
56
|
+
when nothing walks. Collision guarantees are unchanged (the pinned
|
|
57
|
+
loose-vs-canonical conformance case still throws), and symlinked
|
|
58
|
+
off-canonical files are now reachable where the old scan skipped them.
|
|
59
|
+
- **`akm health --report` and `akm proposal list --status accepted` no
|
|
60
|
+
longer crash on proposal rows written before the `changes` metadata
|
|
61
|
+
envelope existed (#858), and `improve` outcome-score salience no longer
|
|
62
|
+
silently computes from zero accepted-counts (#859).** ~89% of real
|
|
63
|
+
archived accepted/rejected rows predate the field and can never recover
|
|
64
|
+
it; `storedToChanges` now treats a fully-absent `changes` key as a
|
|
65
|
+
documented legacy gap (empty change list — the rows still count toward
|
|
66
|
+
accepted/rejected history), `listStateProposals` skips-and-warns on
|
|
67
|
+
genuinely corrupt rows instead of aborting the whole list, and the write
|
|
68
|
+
path still refuses to persist a new proposal without changes.
|
|
69
|
+
- **Task v2/v3 sources are auto-read as v4 instead of hard-failing with
|
|
70
|
+
`TASK_SCHEMA_VERSION_UNSUPPORTED`.** The v4 source gate would have broken
|
|
71
|
+
every pre-0.9.4 scheduled task headlessly on upgrade until the operator
|
|
72
|
+
manually ran `akm migrate apply`. `parseTaskSource` now runs the same
|
|
73
|
+
pure, deterministic migration planners the migrator uses (chained
|
|
74
|
+
v2->v3->v4) entirely in memory, emits a one-line stderr deprecation
|
|
75
|
+
warning, and never writes the file; the hard error survives only for
|
|
76
|
+
sources the deterministic conversion genuinely cannot translate.
|
|
77
|
+
`akm migrate apply` remains the way to rewrite files on disk and silence
|
|
78
|
+
the warning. The planner core moved from `scripts/akm-migrate/` into
|
|
79
|
+
`src/tasks/source/` so there is exactly one copy.
|
|
8
80
|
|
|
9
81
|
## [0.9.3] - 2026-08-29
|
|
10
82
|
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ assistant, including [Claude Code](https://claude.ai/code),
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
**Option 1 — npm package (recommended; requires [Node.js](https://nodejs.org) >=
|
|
19
|
+
**Option 1 — npm package (recommended; requires [Node.js](https://nodejs.org) >= 22):**
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
npm install -g akm-cli
|
package/SECURITY.md
CHANGED
|
@@ -96,7 +96,7 @@ containing secrets or private notes.
|
|
|
96
96
|
|
|
97
97
|
## Known non-issues
|
|
98
98
|
|
|
99
|
-
- **The `akm-cli` npm package requires Node.js >=
|
|
99
|
+
- **The `akm-cli` npm package requires Node.js >= 22 as its bootstrap.** A
|
|
100
100
|
working Bun >= 1.0 is preferred for execution when it is also on `PATH`; old,
|
|
101
101
|
unusable, or absent Bun installations fall back to Node.js. Bun does not
|
|
102
102
|
remove the package's Node.js requirement. Standalone binaries are
|
package/STABILITY.md
CHANGED
|
@@ -220,7 +220,7 @@ enumeration of the whole `proposal` noun group.
|
|
|
220
220
|
| `78` | Configuration error |
|
|
221
221
|
- **Install scripts** — `install.sh` and `install.ps1` URLs; the `--prefix`
|
|
222
222
|
/ `AKM_INSTALL_DIR` environment override.
|
|
223
|
-
- **Runtime** — the npm package requires Node.js >=
|
|
223
|
+
- **Runtime** — the npm package requires Node.js >= 22 as its bootstrap and
|
|
224
224
|
prefers a working Bun >= 1.0 for execution when both are available; old,
|
|
225
225
|
unusable, or absent Bun installations fall back to Node.js. Standalone
|
|
226
226
|
binaries are runtime-free.
|
package/dist/akm
CHANGED
|
@@ -120,8 +120,8 @@ if (contextValid) {
|
|
|
120
120
|
|
|
121
121
|
if (!process.versions.bun) {
|
|
122
122
|
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
123
|
-
if (major <
|
|
124
|
-
console.error("The akm-cli npm package requires Node.js >=
|
|
123
|
+
if (major < 22) {
|
|
124
|
+
console.error("The akm-cli npm package requires Node.js >= 22 to bootstrap.");
|
|
125
125
|
process.exit(1);
|
|
126
126
|
}
|
|
127
127
|
}
|
package/dist/akm-migrate
CHANGED
|
@@ -8,8 +8,8 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
|
|
9
9
|
if (!process.versions.bun) {
|
|
10
10
|
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
11
|
-
if (major <
|
|
12
|
-
console.error("The akm-cli npm package requires Node.js >=
|
|
11
|
+
if (major < 22) {
|
|
12
|
+
console.error("The akm-cli npm package requires Node.js >= 22 to bootstrap.");
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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: the akm-cli npm package bootstraps with Node.js >=
|
|
5
|
+
// Runtime guard: the akm-cli npm package bootstraps with Node.js >= 22
|
|
6
6
|
// (#465, #560), then its launcher prefers a working Bun >= 1.0 when available.
|
|
7
7
|
// The runtime boundary (src/runtime.ts, src/storage/database.ts) supports both.
|
|
8
8
|
// Under Node the CLI must be launched via the
|
|
9
9
|
// `dist/cli-node.mjs` wrapper, which registers the text-import loader hook
|
|
10
10
|
// before this module graph loads; running `node dist/cli.js` directly still
|
|
11
11
|
// works for code paths that touch no embedded text asset, but the wrapper is
|
|
12
|
-
// the supported entry. The hard floor is Node
|
|
13
|
-
//
|
|
12
|
+
// the supported entry. The hard floor is Node 22 (Node 20 support dropped 2026-07; `@clack/core` imports
|
|
13
|
+
// `node:util`'s `styleText` (added in Node 20.12) — Node 18 (EOL) throws at import.
|
|
14
14
|
{
|
|
15
15
|
const isBun = typeof globalThis.Bun !== "undefined";
|
|
16
16
|
if (!isBun) {
|
|
17
17
|
const [major = 0] = (process.versions.node ?? "0").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
18
|
-
const nodeOk = major >=
|
|
18
|
+
const nodeOk = major >= 22;
|
|
19
19
|
if (!nodeOk) {
|
|
20
|
-
console.error("\n ERROR: the akm-cli npm package requires Node.js >=
|
|
20
|
+
console.error("\n ERROR: the akm-cli npm package requires Node.js >= 22.\n" +
|
|
21
21
|
` Detected Node.js ${process.versions.node ?? "unknown"}.\n` +
|
|
22
22
|
" Bun >= 1.0 is optional for execution; it does not replace the Node.js bootstrap.\n" +
|
|
23
23
|
" Upgrade Node.js (https://nodejs.org), or install the runtime-free standalone binary:\n" +
|
|
@@ -15,6 +15,23 @@ export function parseTaskMetadata(row) {
|
|
|
15
15
|
...(metadata.engine !== undefined ? { engine: metadata.engine } : {}),
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* `parseTaskMetadata`, but per-row skip-and-warn instead of throwing (mirrors
|
|
20
|
+
* `listStateProposals`). `decodeTaskHistoryMetadata` already tolerates
|
|
21
|
+
* legacy/additive shapes; only genuine corruption reaches this catch, and a
|
|
22
|
+
* corrupt row must degrade the metric (excluded, not fatal) rather than abort
|
|
23
|
+
* the whole `akm health` computation.
|
|
24
|
+
*/
|
|
25
|
+
export function taskFailureDetail(row) {
|
|
26
|
+
try {
|
|
27
|
+
return parseTaskMetadata(row).detail;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
console.warn(`[akm] Skipping unparseable task_history row in agent-failure-rate (task_id=${row.task_id}, started_at=${row.started_at}): ${message}`);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
18
35
|
/**
|
|
19
36
|
* D8 read-boundary predicate (spec docs/plans/specs/p1b-model-extraction.md
|
|
20
37
|
* §5.3) for `akm health`'s `agentFailureRate`: true for a `task_history` row
|
|
@@ -11,7 +11,7 @@ import { readEvents } from "../../core/events.js";
|
|
|
11
11
|
import { buildTaskRunId, getLoggedRunIds } from "../../core/logs-db.js";
|
|
12
12
|
import { DURATION_UNITS, parseDuration } from "../../core/time.js";
|
|
13
13
|
import { queryTaskHistory } from "../../storage/repositories/task-history-repository.js";
|
|
14
|
-
import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow,
|
|
14
|
+
import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./improve-metrics.js";
|
|
15
15
|
import { readLlmUsageAggregate } from "./llm-usage.js";
|
|
16
16
|
import { computeDegradationMetrics, computeDenominatorFixedCoverage } from "./metrics.js";
|
|
17
17
|
import { buildPerRunSummaries } from "./task-runs.js";
|
|
@@ -154,7 +154,7 @@ export function buildWindowMetrics(db, stateDbPath, since, until, now = () => Da
|
|
|
154
154
|
// isAgentTaskHistoryRow's header comment for the full mapping).
|
|
155
155
|
const agentRows = taskRows.filter((row) => isAgentTaskHistoryRow(row));
|
|
156
156
|
const agentFailures = agentRows.filter((row) => {
|
|
157
|
-
const detail =
|
|
157
|
+
const detail = taskFailureDetail(row);
|
|
158
158
|
return typeof detail?.reason === "string" && detail.reason.length > 0;
|
|
159
159
|
});
|
|
160
160
|
const logBackingRate = taskRowsWithLogs.length === 0 ? 1 : existingLogRows.length / taskRowsWithLogs.length;
|
package/dist/commands/health.js
CHANGED
|
@@ -20,7 +20,7 @@ import { queryTaskHistory } from "../storage/repositories/task-history-repositor
|
|
|
20
20
|
import { pkgVersion } from "../version.js";
|
|
21
21
|
import { collectImproveAdvisories } from "./health/advisories.js";
|
|
22
22
|
import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
|
|
23
|
-
import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow,
|
|
23
|
+
import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
|
|
24
24
|
import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
|
|
25
25
|
import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
|
|
26
26
|
import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
|
|
@@ -130,7 +130,7 @@ function gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now) {
|
|
|
130
130
|
// isAgentTaskHistoryRow's header comment for the full mapping).
|
|
131
131
|
const agentRows = taskRows.filter((row) => isAgentTaskHistoryRow(row));
|
|
132
132
|
const agentFailures = agentRows.filter((row) => {
|
|
133
|
-
const detail =
|
|
133
|
+
const detail = taskFailureDetail(row);
|
|
134
134
|
return typeof detail?.reason === "string" && detail.reason.length > 0;
|
|
135
135
|
});
|
|
136
136
|
const logBackingRate = taskRowsWithLogs.length === 0 ? 1 : existingLogRows.length / taskRowsWithLogs.length;
|
|
@@ -1744,6 +1744,13 @@ function updateOutcomeScores(args) {
|
|
|
1744
1744
|
// inflate counts with proposals from other stashes.
|
|
1745
1745
|
const acceptedCountByRef = new Map();
|
|
1746
1746
|
try {
|
|
1747
|
+
// #858/#859: listStateProposals() now skips-and-warns on individual
|
|
1748
|
+
// unparseable rows (including legacy pre-#578 rows with no
|
|
1749
|
+
// persisted `changes`, which it tolerates directly) instead of
|
|
1750
|
+
// throwing, so this no longer silently zeroes out every ref's
|
|
1751
|
+
// count on a single bad row. The outer try/catch stays as a
|
|
1752
|
+
// defense-in-depth fallback for unexpected failures (e.g. a query
|
|
1753
|
+
// error), not the primary safeguard it used to be.
|
|
1747
1754
|
const acceptedProposals = listStateProposals(outcomeDb, {
|
|
1748
1755
|
status: "accepted",
|
|
1749
1756
|
...(primaryStashDir ? { stashDir: primaryStashDir } : {}),
|
|
@@ -1753,7 +1760,7 @@ function updateOutcomeScores(args) {
|
|
|
1753
1760
|
}
|
|
1754
1761
|
}
|
|
1755
1762
|
catch {
|
|
1756
|
-
// best-effort: if
|
|
1763
|
+
// best-effort: if the query itself fails, accepted counts stay at 0
|
|
1757
1764
|
}
|
|
1758
1765
|
// Update each ref's outcome row and collect the resulting outcome scores.
|
|
1759
1766
|
const rawOutcomeScores = new Map();
|
|
@@ -11,7 +11,7 @@ import { stashDirFor } from "../../core/asset/asset-placement.js";
|
|
|
11
11
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
12
12
|
import { conceptIdForStashFile, displayRefForConceptId } from "../../core/asset/resolve-ref.js";
|
|
13
13
|
import { deriveBundleIds } from "../../core/bundle-id.js";
|
|
14
|
-
import { resolveStashDir } from "../../core/common.js";
|
|
14
|
+
import { isAkmRegistryCachePath, resolveStashDir } from "../../core/common.js";
|
|
15
15
|
import { loadConfig, primaryBundlePath } from "../../core/config/config.js";
|
|
16
16
|
import { UsageError } from "../../core/errors.js";
|
|
17
17
|
import { warn } from "../../core/warn.js";
|
|
@@ -80,10 +80,6 @@ function collectMarkdownFiles(dir, caseInsensitive = false) {
|
|
|
80
80
|
}
|
|
81
81
|
return results;
|
|
82
82
|
}
|
|
83
|
-
function isCachedLintPath(filePath) {
|
|
84
|
-
const posixPath = filePath.replace(/\\/g, "/");
|
|
85
|
-
return posixPath.includes("/.cache/") || posixPath.includes("/registry/");
|
|
86
|
-
}
|
|
87
83
|
/** Peer workflow sources accepted by the source-IR compiler; `.yaml` remains unsupported. */
|
|
88
84
|
function collectWorkflowFiles(dir) {
|
|
89
85
|
if (!fs.existsSync(dir))
|
|
@@ -546,7 +542,7 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
546
542
|
: collectMarkdownFiles(dirPath, true);
|
|
547
543
|
let assetFiles = subdir === "workflows" ? files.filter((file) => path.basename(file).toLowerCase() !== "readme.md") : files;
|
|
548
544
|
if (subdir === "workflows") {
|
|
549
|
-
assetFiles = assetFiles.filter((file) => !
|
|
545
|
+
assetFiles = assetFiles.filter((file) => !isAkmRegistryCachePath(file));
|
|
550
546
|
const ownership = resolveWorkflowLintOwnership(stashRoot, assetFiles);
|
|
551
547
|
assetFiles = ownership.files;
|
|
552
548
|
flagged.push(...ownership.issues);
|
|
@@ -575,7 +571,7 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
575
571
|
// Compare on a separator-normalized copy: on Windows these paths carry
|
|
576
572
|
// backslashes, so the forward-slash substring never matched and --fix
|
|
577
573
|
// rewrote files inside the registry cache.
|
|
578
|
-
if (
|
|
574
|
+
if (isAkmRegistryCachePath(filePath))
|
|
579
575
|
continue;
|
|
580
576
|
const relPath = path.relative(stashRoot, filePath);
|
|
581
577
|
let raw;
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
import { defineCommand } from "citty";
|
|
28
28
|
import { getParsedInvocation } from "../../cli/invocation.js";
|
|
29
29
|
import { parsePositiveIntFlag } from "../../cli/parse-args.js";
|
|
30
|
-
import { defineGroupCommand, defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
|
|
30
|
+
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors, } from "../../cli/shared.js";
|
|
31
31
|
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
|
-
import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksRun, akmTasksSync } from "./tasks.js";
|
|
34
|
+
import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksRun, akmTasksSync, akmTasksSyncPlan } from "./tasks.js";
|
|
35
35
|
/** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
|
|
36
36
|
const bundleArg = {
|
|
37
37
|
bundle: {
|
|
@@ -303,6 +303,15 @@ const tasksHistoryCommand = defineJsonCommand({
|
|
|
303
303
|
output("task-history", result);
|
|
304
304
|
},
|
|
305
305
|
});
|
|
306
|
+
/**
|
|
307
|
+
* #849: `task sync --dry-run`'s exit-code contract, "non-zero when the plan
|
|
308
|
+
* contains removals" — factored out as a pure function (rather than left
|
|
309
|
+
* inline in the command's `run()`) so it's directly unit-testable without
|
|
310
|
+
* driving the whole CLI through a real scheduler backend.
|
|
311
|
+
*/
|
|
312
|
+
export function taskSyncDryRunExitCode(preview) {
|
|
313
|
+
return preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
|
|
314
|
+
}
|
|
306
315
|
const tasksSyncCommand = defineJsonCommand({
|
|
307
316
|
meta: {
|
|
308
317
|
name: "sync",
|
|
@@ -315,10 +324,26 @@ const tasksSyncCommand = defineJsonCommand({
|
|
|
315
324
|
description: "Replace installed bindings with the current invocation",
|
|
316
325
|
default: false,
|
|
317
326
|
},
|
|
327
|
+
"dry-run": {
|
|
328
|
+
type: "boolean",
|
|
329
|
+
description: "Compute and print the full reconcile plan (adds/updates/removes, with owning bundle on every " +
|
|
330
|
+
"removal) without touching the OS scheduler. Zero durable writes. Exits non-zero when the plan " +
|
|
331
|
+
"contains removals.",
|
|
332
|
+
default: false,
|
|
333
|
+
},
|
|
318
334
|
},
|
|
319
335
|
async run({ args }) {
|
|
320
336
|
rejectRetiredTaskTargetFlag();
|
|
321
|
-
const
|
|
337
|
+
const rebind = args.rebind === true;
|
|
338
|
+
if (args["dry-run"] === true) {
|
|
339
|
+
const preview = await akmTasksSyncPlan({}, args.bundle, { rebind });
|
|
340
|
+
output("task-sync-dry-run", preview);
|
|
341
|
+
const exitCode = taskSyncDryRunExitCode(preview);
|
|
342
|
+
if (exitCode !== undefined)
|
|
343
|
+
process.exitCode = exitCode;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const result = await akmTasksSync({}, args.bundle, { rebind });
|
|
322
347
|
output("task-sync", result);
|
|
323
348
|
},
|
|
324
349
|
});
|
|
@@ -36,6 +36,7 @@ import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/sched
|
|
|
36
36
|
import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
|
|
37
37
|
import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
|
|
38
38
|
import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
|
|
39
|
+
import { renderSchedulerSyncPlanPreview } from "../../tasks/scheduler-sync-preview.js";
|
|
39
40
|
import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
|
|
40
41
|
import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
|
|
41
42
|
import { TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
|
|
@@ -326,7 +327,16 @@ export async function akmTasksHistory(input) {
|
|
|
326
327
|
* scans all bundles. Activation happens only through explicit `add --bundle`
|
|
327
328
|
* (or `sync --bundle` on a bundle whose task files are already present).
|
|
328
329
|
*/
|
|
329
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Compute (but never apply) a scheduler sync plan: everything through
|
|
332
|
+
* `finalizeSchedulerSyncPlan`'s final call, stopping strictly before
|
|
333
|
+
* `applySchedulerSyncPlan`. Shared by `akmTasksSync` (applies the plan) and
|
|
334
|
+
* `akmTasksSyncPlan` (#849 `--dry-run`, never applies it) so the two paths
|
|
335
|
+
* can never drift on what "the plan" means. `prepared?.publish`, the one
|
|
336
|
+
* deferred write-producing closure in this pipeline, is returned but never
|
|
337
|
+
* invoked here — only `applySchedulerSyncPlan` may call it.
|
|
338
|
+
*/
|
|
339
|
+
async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
|
|
330
340
|
const resolved = resolveTaskReadBundle(undefined, bundleTarget);
|
|
331
341
|
const config = loadConfig();
|
|
332
342
|
const stashDir = resolved.source.path;
|
|
@@ -393,6 +403,10 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
|
|
|
393
403
|
}
|
|
394
404
|
: {}),
|
|
395
405
|
}, preparedSources);
|
|
406
|
+
return { sched, plan, prepared, warnings };
|
|
407
|
+
}
|
|
408
|
+
export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
|
|
409
|
+
const { sched, plan, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
|
|
396
410
|
await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
|
|
397
411
|
? prepared.publish
|
|
398
412
|
: undefined);
|
|
@@ -406,6 +420,20 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
|
|
|
406
420
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
407
421
|
};
|
|
408
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* `akm task sync --dry-run` (#849): compute the exact same plan
|
|
425
|
+
* `akmTasksSync` would apply, then return a non-mutating preview instead of
|
|
426
|
+
* calling `applySchedulerSyncPlan`. `buildSchedulerSyncPlan` is shared with
|
|
427
|
+
* the real sync path specifically so this can never see a different plan
|
|
428
|
+
* than the one a real sync would apply — and specifically so this function
|
|
429
|
+
* never even holds a reference to a callable `publish` closure past this
|
|
430
|
+
* point: `prepared.publish`, if any, is dropped on the floor here, never
|
|
431
|
+
* invoked. Zero durable writes, mirroring `akm workflow plan`.
|
|
432
|
+
*/
|
|
433
|
+
export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
|
|
434
|
+
const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
|
|
435
|
+
return renderSchedulerSyncPlanPreview(sched.name, plan);
|
|
436
|
+
}
|
|
409
437
|
export async function akmTasksDoctor(deps = {}) {
|
|
410
438
|
const warnings = [];
|
|
411
439
|
let invocation = {
|
|
@@ -82,10 +82,10 @@
|
|
|
82
82
|
import fs from "node:fs";
|
|
83
83
|
import path from "node:path";
|
|
84
84
|
import { applyPostContributorFields, applyPreContributorFields, extractPackageMetadata, } from "../../../indexer/passes/metadata.js";
|
|
85
|
-
import { assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
|
|
85
|
+
import { assetPathCandidatesForName, assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
|
|
86
86
|
import { parseFrontmatter } from "../../asset/frontmatter.js";
|
|
87
87
|
import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js";
|
|
88
|
-
import { recognizeMatch
|
|
88
|
+
import { recognizeMatch } from "../recognize-match.js";
|
|
89
89
|
import { perTypeValidateChecks, skillDirectoryDiagnostics, workflowYamlSourceDiagnostics } from "./akm-lint.js";
|
|
90
90
|
import { applyFoldedMetadata, foldRecognizedMetadata } from "./akm-metadata.js";
|
|
91
91
|
import { hashContent, runBaseValidateChecks } from "./shared.js";
|
|
@@ -252,14 +252,6 @@ function conceptIdForRecognizedType(root, filePath, type) {
|
|
|
252
252
|
const stashDir = stashDirFor(type);
|
|
253
253
|
return stashDir !== undefined ? `${stashDir}/${canonicalName}` : canonicalName;
|
|
254
254
|
}
|
|
255
|
-
function recognizePathCandidates(c, file) {
|
|
256
|
-
if (isReservedFileName(file.fileName) || akmStashAbstains(c.root, file.absPath))
|
|
257
|
-
return [];
|
|
258
|
-
return recognizePathCandidateMatches(file).flatMap((match) => {
|
|
259
|
-
const conceptId = conceptIdForRecognizedType(c.root, file.absPath, match.type);
|
|
260
|
-
return conceptId === undefined ? [] : [conceptId];
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
255
|
function recognize(c, file) {
|
|
264
256
|
// D-R6 (spec §5.1): `index.md` / `log.md` are OKF reserved structural files at
|
|
265
257
|
// every depth — never items. Excluded BEFORE classification so a directory
|
|
@@ -481,9 +473,19 @@ export const akmAdapter = {
|
|
|
481
473
|
".kts",
|
|
482
474
|
],
|
|
483
475
|
recognize,
|
|
484
|
-
recognizePathCandidates,
|
|
485
476
|
renderExecutionSource,
|
|
486
477
|
validate,
|
|
478
|
+
/**
|
|
479
|
+
* Closed-form owner candidates (#857): every physical path spelling that
|
|
480
|
+
* could claim `conceptId` without walking the bundle. `deriveCanonicalAssetNameFromStashRoot`
|
|
481
|
+
* (recognize's own conceptId derivation) is a pure function of (type,
|
|
482
|
+
* filePath); this is its inverse, enumerated for the two placements that
|
|
483
|
+
* function can produce for one conceptId — CANONICAL (authored under the
|
|
484
|
+
* type's own stash subdir) and the LOOSE FALLBACK (authored anywhere else
|
|
485
|
+
* in the bundle, so the canonical name is the file's full path relative to
|
|
486
|
+
* the bundle root instead of the stash subdir). `assetPathCandidatesForName`
|
|
487
|
+
* additionally expands `env`'s `.env`/`<name>.env` duality on each.
|
|
488
|
+
*/
|
|
487
489
|
readCandidates(c, conceptId) {
|
|
488
490
|
const posix = conceptId.replace(/\\/g, "/");
|
|
489
491
|
const slash = posix.indexOf("/");
|
|
@@ -492,9 +494,14 @@ export const akmAdapter = {
|
|
|
492
494
|
const head = posix.slice(0, slash);
|
|
493
495
|
const rest = posix.slice(slash + 1);
|
|
494
496
|
const type = stashDirToType(head);
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
497
|
+
if (type === undefined || rest.length === 0)
|
|
498
|
+
return [];
|
|
499
|
+
const canonical = assetPathCandidatesForName(type, path.join(c.root, head), rest);
|
|
500
|
+
const loose = assetPathCandidatesForName(type, c.root, rest);
|
|
501
|
+
return [...new Set([...canonical, ...loose])].map((candidatePath) => ({
|
|
502
|
+
path: candidatePath,
|
|
503
|
+
conceptId: posix,
|
|
504
|
+
}));
|
|
498
505
|
},
|
|
499
506
|
/**
|
|
500
507
|
* Type-driven placement (§5.1), reproducing `path-resolver.ts#buildDiskCandidates`:
|
|
@@ -57,6 +57,7 @@ import { taskSourceErrorDetail } from "../../../tasks/source-v3.js";
|
|
|
57
57
|
import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
|
|
58
58
|
import { compileWorkflowSource } from "../../../workflows/source-ir/compile.js";
|
|
59
59
|
import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
|
|
60
|
+
import { isAkmRegistryCachePath } from "../../common.js";
|
|
60
61
|
/** Recommended `category` values for facts — `commands/lint/fact-linter.ts:9`. */
|
|
61
62
|
const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
|
|
62
63
|
/** Placeholder markers a workflow stub carries — `commands/lint/workflow-linter.ts:10`. */
|
|
@@ -336,7 +337,7 @@ function lineOf(err) {
|
|
|
336
337
|
* like the established Markdown frontend without inventing another parser.
|
|
337
338
|
*/
|
|
338
339
|
export function workflowYamlSourceDiagnostics(relPath, raw, parsePath, workspaceRoot) {
|
|
339
|
-
if (
|
|
340
|
+
if (isAkmRegistryCachePath(parsePath))
|
|
340
341
|
return { errors: [], warnings: [] };
|
|
341
342
|
const compiled = compileWorkflowSource(raw, { path: parsePath, workspaceRoot });
|
|
342
343
|
if (compiled.ok)
|
|
@@ -369,7 +370,7 @@ export function workflowYamlSourceDiagnostics(relPath, raw, parsePath, workspace
|
|
|
369
370
|
*/
|
|
370
371
|
export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
|
|
371
372
|
const none = { errors: [], warnings: [] };
|
|
372
|
-
if (
|
|
373
|
+
if (isAkmRegistryCachePath(parsePath))
|
|
373
374
|
return none;
|
|
374
375
|
const errors = [];
|
|
375
376
|
const warnings = [];
|
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
* ── validate (spec §6 task validation column) ──
|
|
18
18
|
*
|
|
19
19
|
* Validation enters the canonical task source parser (`parseTaskSource`,
|
|
20
|
-
* task source v4
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* task source v4 native as of P4 — a `version: 3` or `version: 2` document
|
|
21
|
+
* is auto-read through the in-memory migration shim in
|
|
22
|
+
* `parse-task-source.ts`; only a version the shim's deterministic planners
|
|
23
|
+
* cannot convert, or any other unsupported number, fails closed with
|
|
24
|
+
* `TASK_SCHEMA_VERSION_UNSUPPORTED`). That parser owns the closed key sets,
|
|
25
|
+
* the executable-selector XOR, hostile YAML policy, the `akm/command`
|
|
26
|
+
* builtin, bounds, and physical `working-directory` containment. The
|
|
27
|
+
* adapter only translates a parser failure into the format-family
|
|
28
|
+
* diagnostic shape.
|
|
26
29
|
*
|
|
27
30
|
* Conformance oracle (authored, DO NOT modify): fixture
|
|
28
31
|
* `tests/fixtures/bundles/akm-task/` + goldens
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import fs from "node:fs";
|
|
31
31
|
import path from "node:path";
|
|
32
|
-
import { assetPathForName, typeForStashDir } from "../../asset/asset-placement.js";
|
|
32
|
+
import { assetPathCandidatesForName, assetPathForName, typeForStashDir } from "../../asset/asset-placement.js";
|
|
33
33
|
import { dangerousEnvKeyDiagnostics } from "./akm-lint.js";
|
|
34
34
|
import { hashContent } from "./shared.js";
|
|
35
35
|
/** A dotenv bundle is single-component; its one component is `main`. */
|
|
@@ -103,12 +103,6 @@ function conceptIdForPath(type, relativePath) {
|
|
|
103
103
|
const stripped = posix.replace(/\.env$/i, "");
|
|
104
104
|
return stripped.endsWith("/") ? `${stripped}default` : stripped;
|
|
105
105
|
}
|
|
106
|
-
function recognizePathCandidates(_c, file) {
|
|
107
|
-
const type = classify(file.relPath);
|
|
108
|
-
if (type === null || hasSensitiveMarker(file.absPath, type))
|
|
109
|
-
return [];
|
|
110
|
-
return [conceptIdForPath(type, file.relPath)];
|
|
111
|
-
}
|
|
112
106
|
function recognize(c, file) {
|
|
113
107
|
const type = classify(file.relPath);
|
|
114
108
|
if (type === null)
|
|
@@ -172,8 +166,15 @@ export const dotenvAdapter = {
|
|
|
172
166
|
version: "0.9.0",
|
|
173
167
|
extensions: [".env"],
|
|
174
168
|
recognize,
|
|
175
|
-
recognizePathCandidates,
|
|
176
169
|
validate,
|
|
170
|
+
/**
|
|
171
|
+
* Closed-form owner candidates (#857): `env`/`secret` are always authored
|
|
172
|
+
* directly under their own stash subdir (`classify` requires it — no
|
|
173
|
+
* off-canonical loose placement exists for this adapter), so there is no
|
|
174
|
+
* loose-fallback class to enumerate here, unlike `akm-adapter`.
|
|
175
|
+
* `assetPathCandidatesForName` expands `env`'s `.env`/`<name>.env` duality;
|
|
176
|
+
* the sensitive-marker sibling spellings are then layered on each.
|
|
177
|
+
*/
|
|
177
178
|
readCandidates(c, conceptId) {
|
|
178
179
|
const posix = toPosix(conceptId);
|
|
179
180
|
const slash = posix.indexOf("/");
|
|
@@ -184,10 +185,11 @@ export const dotenvAdapter = {
|
|
|
184
185
|
const type = typeForStashDir(head);
|
|
185
186
|
if ((type !== "env" && type !== "secret") || rest.length === 0)
|
|
186
187
|
return [];
|
|
187
|
-
const
|
|
188
|
-
|
|
188
|
+
const primaries = assetPathCandidatesForName(type, path.join(c.root, head), rest);
|
|
189
|
+
const expanded = primaries.flatMap((primary) => type === "env"
|
|
189
190
|
? [primary, primary.replace(/\.env$/i, ".sensitive")]
|
|
190
|
-
: [primary, `${primary}.sensitive`, `${primary}.lock`])
|
|
191
|
+
: [primary, `${primary}.sensitive`, `${primary}.lock`]);
|
|
192
|
+
return expanded.map((candidatePath) => ({ path: candidatePath, conceptId: posix }));
|
|
191
193
|
},
|
|
192
194
|
/**
|
|
193
195
|
* env places to `env/<name>.env`, secret to `secrets/<name>` (identity join,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
import { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher
|
|
4
|
+
import { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher } from "../../indexer/walk/matchers.js";
|
|
5
5
|
/**
|
|
6
6
|
* The four builtin matchers, in registration order. The array index IS the
|
|
7
7
|
* registration index `runMatchers` uses for tie-breaking. (The `wiki` matcher
|
|
@@ -46,22 +46,3 @@ export function recognizeMatch(file) {
|
|
|
46
46
|
}
|
|
47
47
|
return winningMatch(hits);
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Every AKM matcher winner possible from path fields alone. The three
|
|
51
|
-
* path-only matchers run exactly as production does; each possible result of
|
|
52
|
-
* the shared smart-Markdown fact table is then arbitrated at its real index.
|
|
53
|
-
*/
|
|
54
|
-
export function recognizePathCandidateMatches(file) {
|
|
55
|
-
const fileContext = file;
|
|
56
|
-
const pathHits = [extensionMatcher, directoryMatcher, parentDirHintMatcher].flatMap((matcher, index) => {
|
|
57
|
-
const result = matcher(fileContext);
|
|
58
|
-
return result ? [{ result, index }] : [];
|
|
59
|
-
});
|
|
60
|
-
const smartCandidates = smartMdPathCandidates(file);
|
|
61
|
-
const variants = smartCandidates.length > 0 ? smartCandidates : [undefined];
|
|
62
|
-
const winners = variants.flatMap((smart) => {
|
|
63
|
-
const winner = winningMatch(smart ? [...pathHits, { result: smart, index: 3 }] : [...pathHits]);
|
|
64
|
-
return winner ? [winner] : [];
|
|
65
|
-
});
|
|
66
|
-
return [...new Map(winners.map((winner) => [`${winner.type}\0${winner.renderer}`, winner])).values()];
|
|
67
|
-
}
|