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.
Files changed (43) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/preparation.js +8 -1
  12. package/dist/commands/lint/index.js +3 -7
  13. package/dist/commands/tasks/tasks-cli.js +28 -3
  14. package/dist/commands/tasks/tasks.js +29 -1
  15. package/dist/core/adapter/adapters/akm-adapter.js +21 -14
  16. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  17. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  18. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  19. package/dist/core/adapter/recognize-match.js +1 -20
  20. package/dist/core/asset/asset-placement.js +21 -2
  21. package/dist/core/common.js +21 -1
  22. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  23. package/dist/indexer/search/db-search.js +6 -0
  24. package/dist/indexer/walk/matchers.js +0 -22
  25. package/dist/output/shapes/helpers.js +19 -1
  26. package/dist/output/shapes/passthrough.js +1 -0
  27. package/dist/output/text/command-format.js +4 -0
  28. package/dist/registry/pinned-request-helper.js +2 -2
  29. package/dist/registry/pinned-transport.js +6 -6
  30. package/dist/scripts/akm-migrate-node.js +12624 -12555
  31. package/dist/scripts/akm-migrate.js +12624 -12555
  32. package/dist/storage/repositories/proposals-repository.js +33 -1
  33. package/dist/storage/repositories/task-history-repository.js +22 -10
  34. package/dist/tasks/run/task-history.js +23 -3
  35. package/dist/tasks/scheduler-sync-preview.js +43 -0
  36. package/dist/tasks/scheduler-sync.js +1 -0
  37. package/dist/tasks/source/bounded-document.js +1 -1
  38. package/dist/tasks/source/parse-task-source.js +77 -11
  39. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  40. package/dist/tasks/source/task-to-v3.js +500 -0
  41. package/dist/tasks/source/task-to-v4.js +467 -0
  42. package/docs/reference/cli.md +8 -3
  43. 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
- ## [Unreleased]
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) >= 24):**
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 >= 24 as its bootstrap.** A
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 >= 24 as its bootstrap and
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 < 24) {
124
- console.error("The akm-cli npm package requires Node.js >= 24 to bootstrap.");
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 < 24) {
12
- console.error("The akm-cli npm package requires Node.js >= 24 to bootstrap.");
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 >= 24
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 24; Node 22 is no longer a
13
- // supported npm bootstrap runtime.
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 >= 24;
18
+ const nodeOk = major >= 22;
19
19
  if (!nodeOk) {
20
- console.error("\n ERROR: the akm-cli npm package requires Node.js >= 24.\n" +
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, parseTaskMetadata, roundRate, summarizeImproveCompleted, summarizeImproveRuns, } from "./improve-metrics.js";
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 = parseTaskMetadata(row).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;
@@ -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, parseTaskMetadata, roundRate, summarizeImproveCompleted, summarizeImproveRuns, } from "./health/improve-metrics.js";
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 = parseTaskMetadata(row).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 proposals query fails, accepted counts stay at 0
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) => !isCachedLintPath(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 (isCachedLintPath(filePath))
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 result = await akmTasksSync({}, args.bundle, { rebind: args.rebind === true });
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
- export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
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, recognizePathCandidateMatches } from "../recognize-match.js";
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
- return type === undefined || rest.length === 0
496
- ? []
497
- : [{ path: assetPathForName(type, path.join(c.root, head), rest), conceptId: posix }];
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 (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
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 (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
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 only as of P4 — a `version: 3` or `version: 2` document now
21
- * fails closed with `TASK_SCHEMA_VERSION_UNSUPPORTED`). That parser owns the
22
- * closed key sets, the executable-selector XOR, hostile YAML policy, the
23
- * `akm/command` builtin, bounds, and physical `working-directory`
24
- * containment. The adapter only translates a parser failure into the
25
- * format-family diagnostic shape.
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 primary = assetPathForName(type, path.join(c.root, head), rest);
188
- return (type === "env"
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`]).map((candidatePath) => ({ path: candidatePath, conceptId: posix }));
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, smartMdPathCandidates, } from "../../indexer/walk/matchers.js";
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
- }