akm-cli 0.9.17-alpha.1 → 0.9.17-alpha.3

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 CHANGED
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.17-alpha.3] - 2026-09-24
10
+
11
+ ### Fixed
12
+
13
+ - **Unscoped `akm task sync` no longer aborts the whole host on the first
14
+ bundle root that happens to contain any symlink.** `captureGuardedDirectoryManifest`
15
+ threw for every symbolic directory entry it listed, even one the scheduler
16
+ never reads (e.g. a third-party skill repo's `CLAUDE.md -> AGENTS.md`) —
17
+ `SchedulerSourceCollector` manifests every scanned bundle's root, so one
18
+ such bundle among many enabled ones failed sync entirely, dry-run included.
19
+ A symlink that stays inside its bundle root is now recorded in the guarded
20
+ directory manifest as its own `"symlink"` kind, identified without
21
+ following it (its `readlink` text plus its no-follow `lstat` identity), so
22
+ change detection still works; it is never read or descended into. A symlink
23
+ sitting exactly where a task or workflow source lives (a `.yml` under
24
+ `tasks/`, any `.yml` under an `akm-task` bundle, or a workflow-named file
25
+ under `workflows/`) is reported as its own per-source failure — "is a
26
+ symbolic source; guarded reads require a regular no-follow owner" — and its
27
+ ref is not scheduled, even when a real sibling file shares that ref, while
28
+ every other task and workflow still reconciles. A
29
+ symlink that resolves outside the bundle root, or one that is broken and
30
+ cannot be identified safely, is still refused, and a bundle root whose
31
+ `tasks` or `workflows` entry is itself a symlink still refuses loudly,
32
+ since that is a schedulable source location.
33
+
34
+ ## [0.9.17-alpha.2] - 2026-09-24
35
+
36
+ ### Fixed
37
+
38
+ - **A config carrying the retired `experimental.workflowEngine` key no longer
39
+ fails to load.** `ExperimentalConfigSchema` moved from `.passthrough()` to
40
+ `.strict()` in 0.9.16 (`cc6152e02`), after `workflowEngine` had already been
41
+ removed from it in `e0655d13c`; a real config a 0.9.15 install wrote (whose
42
+ passthrough still accepted the key) then failed every command with
43
+ `Invalid config: experimental: Unrecognized key(s) in object: 'workflowEngine'`.
44
+ The config loader now strips known-retired `experimental.*` keys in memory
45
+ before validation, warning once and naming `akm migrate apply`; a genuinely
46
+ unknown/misspelled key (e.g. `improveAutonomyy`) still fails closed.
47
+ `akm migrate apply` removes the retired key from `config.json` on disk
48
+ (with the usual backup), and `--dry-run` reports the pending removal.
49
+ - **Unscoped `akm task sync` no longer crashes when an enabled website or npm
50
+ bundle is configured.** The sync plan loop resolved every active source
51
+ through the write-target resolver, which rejects any kind other than
52
+ `filesystem`/`git` outright (writes, and therefore scheduler state, are
53
+ undefined for those kinds — the same rejection `akm task enable` already
54
+ hit). Unscoped sync now skips non-filesystem/git bundles when building
55
+ install operations — they never carried schedulable tasks — while
56
+ inactive-bundle removal/revocation still sees them. A scoped
57
+ `akm task sync --bundle <website-or-npm-bundle>` now fails with a clear
58
+ usage error instead of the write-target `ConfigError`.
59
+
9
60
  ## [0.9.17-alpha.1] - 2026-09-24
10
61
 
11
62
  ### Added
@@ -23,7 +23,7 @@ import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../cor
23
23
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
24
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
25
25
  import { warn } from "../../core/warn.js";
26
- import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
26
+ import { commitWriteTargetBoundary, deleteAssetFromSource, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
27
27
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
28
28
  import { resolveAssetPath } from "../../sources/resolve.js";
29
29
  import { activeSchedulerActivations, isSchedulerRefEnabled, setSchedulerRefEnabled, } from "../../tasks/activation-config.js";
@@ -411,7 +411,24 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
411
411
  const nativeArtifacts = inspection.artifacts;
412
412
  const configuredSources = resolveConfiguredSources(config);
413
413
  const activeSources = resolveActiveConfiguredSources(config);
414
- const sourceNames = bundleTarget ? [bundleTarget] : activeSources.map((source) => source.name);
414
+ if (bundleTarget) {
415
+ // adaptConfiguredSource (src/core/write-source.ts) rejects any kind
416
+ // other than filesystem/git outright, so a website/npm bundle can never
417
+ // carry scheduler state (akm task enable already fails the same way).
418
+ // Surface that as a clear usage error here instead of letting the
419
+ // write-target resolution below raise a generic ConfigError.
420
+ const targetSource = activeSources.find((source) => source.name === bundleTarget);
421
+ if (targetSource && !isWriteCapableSourceKind(targetSource.type)) {
422
+ throw new UsageError(`Bundle "${bundleTarget}" has kind "${targetSource.type}"; task scheduling is only supported for filesystem and git bundles.`, "INVALID_FLAG_VALUE");
423
+ }
424
+ }
425
+ // Unscoped sync only installs/removes bindings for bundles that can carry
426
+ // them (filesystem/git). A website/npm bundle contributes no installs and
427
+ // must not crash the loop; inactiveOperations below still sees it via
428
+ // configuredSources for removal/revocation.
429
+ const sourceNames = bundleTarget
430
+ ? [bundleTarget]
431
+ : activeSources.filter((source) => isWriteCapableSourceKind(source.type)).map((source) => source.name);
415
432
  const inactiveOperations = bundleTarget
416
433
  ? []
417
434
  : inactiveBundleRemovalOperations(config, configuredSources, allEntries, nativeArtifacts);
@@ -16,6 +16,7 @@ import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundlesTo
16
16
  import { upgradeConfigVersion } from "./config-version-shim.js";
17
17
  import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
18
18
  import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
19
+ import { stripRetiredExperimentalKeys } from "./retired-experimental-keys-shim.js";
19
20
  import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
20
21
  export { stripJsonComments } from "./config-io.js";
21
22
  import { getConfigPath } from "../paths.js";
@@ -147,7 +148,8 @@ export function acquireConfigReadFence() {
147
148
  * before it is either validated (the local/top-level file) or merged in as
148
149
  * an `extends` base: JSONC parse already done by the caller, then version
149
150
  * shim, then legacy `stashDir`/`sources[]`/`installed[]` shim, then the
150
- * legacy `extraParams` lift (#852). Shared by {@link parseAndValidateConfigText}
151
+ * legacy `extraParams` lift (#852), then the retired `experimental.*` key
152
+ * shim. Shared by {@link parseAndValidateConfigText}
151
153
  * (the local file) and {@link resolveExtendsChain} (each base in the chain) so
152
154
  * a fleet-shared base config can carry its own old `configVersion` / legacy
153
155
  * shape independently of the file that extends it.
@@ -155,7 +157,8 @@ export function acquireConfigReadFence() {
155
157
  function runConfigFilePipeline(text, sourcePath) {
156
158
  const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
157
159
  const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
158
- return liftExtraParamsOrThrow(parsedRaw, sourcePath);
160
+ const liftedRaw = liftExtraParamsOrThrow(parsedRaw, sourcePath);
161
+ return stripRetiredExperimentalKeys(liftedRaw, sourcePath);
159
162
  }
160
163
  /**
161
164
  * #852 (following #815): a config still using legacy `extraParams` keys —
@@ -0,0 +1,62 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `experimental.*` retired-key shim.
6
+ *
7
+ * `ExperimentalConfigSchema` (`./schema/experimental.ts`) moved from
8
+ * `.passthrough()` to `.strict()` in 0.9.16 (`cc6152e02`) so a typo in an
9
+ * authority flag (e.g. `improveAutonomyy`) fails loudly instead of silently
10
+ * doing nothing. `workflowEngine` was removed from that block earlier, in
11
+ * `e0655d13c`, but 0.9.15's passthrough still accepted it — so a real config
12
+ * written by 0.9.15 can carry `experimental.workflowEngine` and now fails
13
+ * every command with `Invalid config: experimental: Unrecognized key(s)`.
14
+ *
15
+ * Per AGENTS.md "Reading persisted data": a reader tolerates what older
16
+ * releases wrote, converts in memory, warns once, and leaves the on-disk
17
+ * rewrite to `akm migrate apply`. This mirrors `./legacy-source-shape-shim.ts`
18
+ * exactly — strip the retired key(s) before schema validation, warn once
19
+ * naming the key and the migrate command — rather than reintroducing
20
+ * `.passthrough()`, which would also let a live key typo through silently.
21
+ */
22
+ import { isRecord } from "../common.js";
23
+ import { warnOnce } from "../warn.js";
24
+ /**
25
+ * Every `experimental.*` key `ExperimentalConfigSchema` has ever retired.
26
+ * `workflowEngine` (removed in `e0655d13c`) is the only one so far — kept as
27
+ * a list because the shim and `akm migrate apply`
28
+ * (`scripts/akm-migrate/migrate/config-retired-experimental-keys.ts`) share
29
+ * it.
30
+ */
31
+ export const RETIRED_EXPERIMENTAL_KEYS = ["workflowEngine"];
32
+ /**
33
+ * Which `RETIRED_EXPERIMENTAL_KEYS` are present in a raw config's
34
+ * `experimental` section. Returns `[]` when `raw.experimental` is missing or
35
+ * not a record. Shared by `stripRetiredExperimentalKeys` below and by
36
+ * `akm migrate apply`'s on-disk counterpart
37
+ * (`scripts/akm-migrate/migrate/config-retired-experimental-keys.ts`).
38
+ */
39
+ export function retiredExperimentalKeysIn(raw) {
40
+ const experimental = raw.experimental;
41
+ if (!isRecord(experimental))
42
+ return [];
43
+ return RETIRED_EXPERIMENTAL_KEYS.filter((key) => key in experimental);
44
+ }
45
+ /**
46
+ * Drop retired `experimental.*` keys from a raw parsed config object before
47
+ * schema validation, warning once per source when any were present. Live
48
+ * keys (including an unrecognized one, e.g. a typo) are left untouched for
49
+ * `ExperimentalConfigSchema.strict()` to reject as before.
50
+ */
51
+ export function stripRetiredExperimentalKeys(raw, sourcePath) {
52
+ const present = retiredExperimentalKeysIn(raw);
53
+ if (present.length === 0)
54
+ return raw;
55
+ const original = raw.experimental;
56
+ const experimental = { ...original };
57
+ for (const key of present)
58
+ delete experimental[key];
59
+ const where = sourcePath ? ` at ${sourcePath}` : "";
60
+ warnOnce(`config:retired-experimental-key${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired experimental key(s) ${present.join(", ")} — ignored in memory. Run \`akm migrate apply\` to remove ${present.length === 1 ? "it" : "them"} from the config file and silence this warning.`);
61
+ return { ...raw, experimental };
62
+ }
@@ -1028,6 +1028,14 @@ export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
1028
1028
  return;
1029
1029
  throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
1030
1030
  }
1031
+ /**
1032
+ * The write-capable source kinds. Writes (and therefore scheduler state,
1033
+ * which only ever binds to a writable source) are defined only for these
1034
+ * two kinds; anything else throws `ConfigError`.
1035
+ */
1036
+ export function isWriteCapableSourceKind(kind) {
1037
+ return kind === "filesystem" || kind === "git";
1038
+ }
1031
1039
  /**
1032
1040
  * Reject any kind reaching the write/delete helpers other than the two
1033
1041
  * supported writable kinds. The config loader is the first line of defence
@@ -1035,7 +1043,7 @@ export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
1035
1043
  * bypass the loader still get a clear error.
1036
1044
  */
1037
1045
  function assertSupportedKind(source) {
1038
- if (source.kind === "filesystem" || source.kind === "git")
1046
+ if (isWriteCapableSourceKind(source.kind))
1039
1047
  return;
1040
1048
  throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
1041
1049
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
@@ -1073,7 +1081,7 @@ function adaptConfiguredSource(runtime) {
1073
1081
  // reaching this point is a config-loader bug (assertWritableAllowedForKind
1074
1082
  // should have rejected it). Throw a ConfigError rather than silently
1075
1083
  // forwarding an unsupported kind.
1076
- if (runtime.type !== "filesystem" && runtime.type !== "git") {
1084
+ if (!isWriteCapableSourceKind(runtime.type)) {
1077
1085
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
1078
1086
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
1079
1087
  }
@@ -179,6 +179,18 @@ export function captureGuardedDirectoryManifest(directoryPathInput, containmentR
179
179
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
180
180
  throw new UsageError(`${entryPath} resolves outside the bundle root through a symbolic source.`, "PATH_ESCAPE_VIOLATION");
181
181
  }
182
+ // A symlink that stays inside the containment root is recorded as
183
+ // its own kind, identified without following it (readlink text
184
+ // plus its own no-follow lstat identity), so change detection
185
+ // still works. It is never a directory or file candidate to any
186
+ // manifest consumer.
187
+ return Object.freeze({
188
+ name: entry.name,
189
+ kind: "symlink",
190
+ physicalIdentity: physicalIdentity(entryPath, entryStat),
191
+ version: statVersion(entryStat),
192
+ target: fs.readlinkSync(entryPath),
193
+ });
182
194
  }
183
195
  throw new UsageError(`${entryPath} is a symbolic source with a physical source identity collision; guarded reads require one no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
184
196
  }
@@ -323,8 +335,10 @@ export class GuardedExecutionSourceCollector {
323
335
  const candidate = path.join(directory, entry.name);
324
336
  if (entry.kind === "directory")
325
337
  visit(candidate);
326
- else
338
+ else if (entry.kind === "file")
327
339
  files.push(candidate);
340
+ // A "symlink" entry is recorded for change detection but is never
341
+ // read, descended into, or made a candidate source.
328
342
  }
329
343
  };
330
344
  visit(path.resolve(directoryPath));
@@ -77671,6 +77671,31 @@ function upgradeConfigVersion(raw, sourcePath) {
77671
77671
 
77672
77672
  // src/core/config/config.ts
77673
77673
  init_legacy_source_shape_shim();
77674
+
77675
+ // src/core/config/retired-experimental-keys-shim.ts
77676
+ init_common();
77677
+ init_warn();
77678
+ var RETIRED_EXPERIMENTAL_KEYS = ["workflowEngine"];
77679
+ function retiredExperimentalKeysIn(raw) {
77680
+ const experimental = raw.experimental;
77681
+ if (!isRecord(experimental))
77682
+ return [];
77683
+ return RETIRED_EXPERIMENTAL_KEYS.filter((key) => (key in experimental));
77684
+ }
77685
+ function stripRetiredExperimentalKeys(raw, sourcePath) {
77686
+ const present = retiredExperimentalKeysIn(raw);
77687
+ if (present.length === 0)
77688
+ return raw;
77689
+ const original = raw.experimental;
77690
+ const experimental = { ...original };
77691
+ for (const key of present)
77692
+ delete experimental[key];
77693
+ const where = sourcePath ? ` at ${sourcePath}` : "";
77694
+ warnOnce(`config:retired-experimental-key${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired experimental key(s) ${present.join(", ")} — ignored in memory. Run \`akm migrate apply\` to remove ${present.length === 1 ? "it" : "them"} from the config file and silence this warning.`);
77695
+ return { ...raw, experimental };
77696
+ }
77697
+
77698
+ // src/core/config/config.ts
77674
77699
  init_paths();
77675
77700
  init_warn();
77676
77701
  var DEFAULT_CONFIG = {
@@ -77730,7 +77755,8 @@ function loadUserConfig() {
77730
77755
  function runConfigFilePipeline(text, sourcePath) {
77731
77756
  const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
77732
77757
  const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
77733
- return liftExtraParamsOrThrow(parsedRaw, sourcePath);
77758
+ const liftedRaw = liftExtraParamsOrThrow(parsedRaw, sourcePath);
77759
+ return stripRetiredExperimentalKeys(liftedRaw, sourcePath);
77734
77760
  }
77735
77761
  function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
77736
77762
  const where = sourcePath ? ` at ${sourcePath}` : "";
@@ -86389,8 +86415,11 @@ function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
86389
86415
  return;
86390
86416
  throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
86391
86417
  }
86418
+ function isWriteCapableSourceKind(kind) {
86419
+ return kind === "filesystem" || kind === "git";
86420
+ }
86392
86421
  function adaptConfiguredSource(runtime) {
86393
- if (runtime.type !== "filesystem" && runtime.type !== "git") {
86422
+ if (!isWriteCapableSourceKind(runtime.type)) {
86394
86423
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` + "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
86395
86424
  }
86396
86425
  const kind = runtime.type;
@@ -93226,32 +93255,7 @@ var GLOBAL_OUTPUT_ARGS2 = {
93226
93255
  init_errors();
93227
93256
 
93228
93257
  // scripts/akm-migrate/help.txt
93229
- var help_default = `Usage: akm-migrate <command> [options]
93230
-
93231
- The one migration tool for an akm installation. Every historical shape akm
93232
- has ever written lives here; the CLI proper reads only current schemas.
93233
- \`status\` and \`apply\` run every step, in order, and print one combined JSON
93234
- plan (exit 1 when any step is blocked):
93235
-
93236
- 1. legacy config \`extraParams\` keys lifted onto first-class engine fields
93237
- 2. scheduler grants bound to the configured source installation that was
93238
- approved (stale grants whose bundle no longer exists are removed)
93239
- 3. pending state.db migrations, historical-destructive ones included,
93240
- with a verified sibling safety copy (the only path that admits them)
93241
- 4. task-v2 files to task v3, then task-v3 files to task source v4
93242
- 5. superseded pre-0.9.0 \`.akm\` residue and stale filesystem transactions
93243
- 6. live \`.akm\` writers relocated to \`$STATE\`/\`$CACHE\`, for every local
93244
- bundle (distill-rejected, eval-cases, measurement verdicts, and stale
93245
- improve-pipeline locks — a lock a live run still holds is left alone)
93246
-
93247
- \`akm migrate status|apply\` wraps this executable; \`akm upgrade\` runs
93248
- \`apply\` after its install step, so an image that ships akm can put either
93249
- in its entrypoint (a current installation is a no-op).
93250
-
93251
- Commands:
93252
- status Inspect every pending migration without changing anything.
93253
- apply [--dry-run] Back up and apply every pending migration.
93254
- `;
93258
+ var help_default = "Usage: akm-migrate <command> [options]\n\nThe one migration tool for an akm installation. Every historical shape akm\nhas ever written lives here; the CLI proper reads only current schemas.\n`status` and `apply` run every step, in order, and print one combined JSON\nplan (exit 1 when any step is blocked):\n\n 1. legacy config `extraParams` keys lifted onto first-class engine fields\n 2. retired `experimental.*` config keys removed (today `workflowEngine`)\n 3. scheduler grants bound to the configured source installation that was\n approved (stale grants whose bundle no longer exists are removed)\n 4. pending state.db migrations, historical-destructive ones included,\n with a verified sibling safety copy (the only path that admits them)\n 5. source-owned schedule enablement converted to host-local scheduler\n grants\n 6. task-v2 files to task v3, then task-v3 files to task source v4\n 7. superseded pre-0.9.0 `.akm` residue and stale filesystem transactions\n 8. live `.akm` writers relocated to `$STATE`/`$CACHE`, for every local\n bundle (distill-rejected, eval-cases, measurement verdicts, and stale\n improve-pipeline locks — a lock a live run still holds is left alone)\n\n`akm migrate status|apply` wraps this executable; `akm upgrade` runs\n`apply` after its install step, so an image that ships akm can put either\nin its entrypoint (a current installation is a no-op).\n\nCommands:\n status Inspect every pending migration without changing anything.\n apply [--dry-run] Back up and apply every pending migration.\n";
93255
93259
 
93256
93260
  // scripts/akm-migrate/run-migrate.ts
93257
93261
  init_common();
@@ -94787,10 +94791,44 @@ function applyConfigExtraParamsLift(configPath) {
94787
94791
  return { applied: true, lifted, conflicts: [] };
94788
94792
  }
94789
94793
 
94794
+ // scripts/akm-migrate/migrate/config-retired-experimental-keys.ts
94795
+ function readRawConfig2(configPath) {
94796
+ const text = readConfigText(configPath);
94797
+ if (text === undefined)
94798
+ return;
94799
+ return parseConfigText(text, configPath);
94800
+ }
94801
+ function findConfigRetiredExperimentalKeys(configPath) {
94802
+ const raw = readRawConfig2(configPath);
94803
+ if (!raw)
94804
+ return { removed: [] };
94805
+ return { removed: retiredExperimentalKeysIn(raw).map((key) => `experimental.${key}`) };
94806
+ }
94807
+ function applyConfigRetiredExperimentalKeys(configPath) {
94808
+ const raw = readRawConfig2(configPath);
94809
+ if (!raw)
94810
+ return { applied: false, removed: [] };
94811
+ const retired = retiredExperimentalKeysIn(raw);
94812
+ if (retired.length === 0)
94813
+ return { applied: false, removed: [] };
94814
+ const experimental = { ...raw.experimental };
94815
+ for (const key of retired)
94816
+ delete experimental[key];
94817
+ const config = { ...raw, experimental };
94818
+ const release = acquireConfigLock();
94819
+ try {
94820
+ backupExistingConfig(configPath);
94821
+ writeConfigAtomic(configPath, config);
94822
+ } finally {
94823
+ release();
94824
+ }
94825
+ return { applied: true, removed: retired.map((key) => `experimental.${key}`) };
94826
+ }
94827
+
94790
94828
  // scripts/akm-migrate/migrate/config-scheduler-source-ids.ts
94791
94829
  init_asset_ref();
94792
94830
  init_bundle_id();
94793
- function readRawConfig2(configPath) {
94831
+ function readRawConfig3(configPath) {
94794
94832
  const text = readConfigText(configPath);
94795
94833
  return text === undefined ? undefined : parseConfigText(text, configPath);
94796
94834
  }
@@ -94845,13 +94883,13 @@ function implicitBundleSourceId(raw, bundleId) {
94845
94883
  return implicitId === bundleId ? filesystemBundleSourceId(root) : undefined;
94846
94884
  }
94847
94885
  function findConfigSchedulerSourceIdMigration(configPath) {
94848
- const raw = readRawConfig2(configPath);
94886
+ const raw = readRawConfig3(configPath);
94849
94887
  return Object.freeze({ changes: Object.freeze(raw ? migrationChanges(raw) : []) });
94850
94888
  }
94851
94889
  function applyConfigSchedulerSourceIdMigration(configPath) {
94852
94890
  const release = acquireConfigLock();
94853
94891
  try {
94854
- const raw = readRawConfig2(configPath);
94892
+ const raw = readRawConfig3(configPath);
94855
94893
  if (!raw)
94856
94894
  return Object.freeze({ applied: false, changes: Object.freeze([]) });
94857
94895
  const changes = migrationChanges(raw);
@@ -104071,12 +104109,16 @@ async function runMigration(options) {
104071
104109
  if (apply && configExtraParams.applied)
104072
104110
  resetConfigCache();
104073
104111
  const pendingLift = apply ? undefined : configExtraParams.pending;
104112
+ const configRetiredExperimentalKeys = apply ? applyConfigRetiredExperimentalKeys(configPath) : { pending: findConfigRetiredExperimentalKeys(configPath) };
104113
+ if (apply && configRetiredExperimentalKeys.applied)
104114
+ resetConfigCache();
104074
104115
  if (pendingLift && pendingLift.lifted.length > 0) {
104075
104116
  return {
104076
104117
  schemaVersion: 1,
104077
104118
  status: "blocked",
104078
104119
  blockers: pendingLift.lifted,
104079
104120
  configExtraParams,
104121
+ configRetiredExperimentalKeys,
104080
104122
  stateMigrations: { pending: listPendingStateMigrations() }
104081
104123
  };
104082
104124
  }
@@ -104091,6 +104133,7 @@ async function runMigration(options) {
104091
104133
  blockers: pendingSchedulerBindings.changes.map((change) => `${change.kind === "bind" ? "bind" : "drop"} scheduler activation ${change.ref}` + (change.reason ? `: ${change.reason}` : "")),
104092
104134
  configExtraParams,
104093
104135
  configSchedulerSourceIds,
104136
+ configRetiredExperimentalKeys,
104094
104137
  stateMigrations: { pending: listPendingStateMigrations() }
104095
104138
  };
104096
104139
  }
@@ -104110,12 +104153,14 @@ async function runMigration(options) {
104110
104153
  }
104111
104154
  const stateStatus = "pending" in stateMigrations && stateMigrations.pending.length > 0 ? "ready" : "current";
104112
104155
  const schedulerStatus = "pending" in schedulerActivation && schedulerActivation.pending.length > 0 ? "ready" : "current";
104156
+ const retiredKeysStatus = "pending" in configRetiredExperimentalKeys && configRetiredExperimentalKeys.pending.removed.length > 0 ? "ready" : "current";
104113
104157
  return {
104114
104158
  schemaVersion: 1,
104115
- status: worstStatus(worstStatus(worstStatus(taskV3.status, taskV4.status), stateStatus), schedulerStatus),
104159
+ status: worstStatus(worstStatus(worstStatus(worstStatus(taskV3.status, taskV4.status), stateStatus), schedulerStatus), retiredKeysStatus),
104116
104160
  blockers: [...taskV3.blockers, ...taskV4.blockers],
104117
104161
  configExtraParams,
104118
104162
  configSchedulerSourceIds,
104163
+ configRetiredExperimentalKeys,
104119
104164
  stateMigrations,
104120
104165
  schedulerActivation,
104121
104166
  taskV3Migration: taskV3.taskV3Migration,
@@ -76999,6 +76999,31 @@ function upgradeConfigVersion(raw, sourcePath) {
76999
76999
 
77000
77000
  // src/core/config/config.ts
77001
77001
  init_legacy_source_shape_shim();
77002
+
77003
+ // src/core/config/retired-experimental-keys-shim.ts
77004
+ init_common();
77005
+ init_warn();
77006
+ var RETIRED_EXPERIMENTAL_KEYS = ["workflowEngine"];
77007
+ function retiredExperimentalKeysIn(raw) {
77008
+ const experimental = raw.experimental;
77009
+ if (!isRecord(experimental))
77010
+ return [];
77011
+ return RETIRED_EXPERIMENTAL_KEYS.filter((key) => (key in experimental));
77012
+ }
77013
+ function stripRetiredExperimentalKeys(raw, sourcePath) {
77014
+ const present = retiredExperimentalKeysIn(raw);
77015
+ if (present.length === 0)
77016
+ return raw;
77017
+ const original = raw.experimental;
77018
+ const experimental = { ...original };
77019
+ for (const key of present)
77020
+ delete experimental[key];
77021
+ const where = sourcePath ? ` at ${sourcePath}` : "";
77022
+ warnOnce(`config:retired-experimental-key${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired experimental key(s) ${present.join(", ")} \u2014 ignored in memory. Run \`akm migrate apply\` to remove ${present.length === 1 ? "it" : "them"} from the config file and silence this warning.`);
77023
+ return { ...raw, experimental };
77024
+ }
77025
+
77026
+ // src/core/config/config.ts
77002
77027
  init_paths();
77003
77028
  init_warn();
77004
77029
  var DEFAULT_CONFIG = {
@@ -77058,7 +77083,8 @@ function loadUserConfig() {
77058
77083
  function runConfigFilePipeline(text, sourcePath) {
77059
77084
  const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
77060
77085
  const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
77061
- return liftExtraParamsOrThrow(parsedRaw, sourcePath);
77086
+ const liftedRaw = liftExtraParamsOrThrow(parsedRaw, sourcePath);
77087
+ return stripRetiredExperimentalKeys(liftedRaw, sourcePath);
77062
77088
  }
77063
77089
  function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
77064
77090
  const where = sourcePath ? ` at ${sourcePath}` : "";
@@ -85717,8 +85743,11 @@ function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
85717
85743
  return;
85718
85744
  throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
85719
85745
  }
85746
+ function isWriteCapableSourceKind(kind) {
85747
+ return kind === "filesystem" || kind === "git";
85748
+ }
85720
85749
  function adaptConfiguredSource(runtime) {
85721
- if (runtime.type !== "filesystem" && runtime.type !== "git") {
85750
+ if (!isWriteCapableSourceKind(runtime.type)) {
85722
85751
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` + "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
85723
85752
  }
85724
85753
  const kind = runtime.type;
@@ -93182,32 +93211,7 @@ var GLOBAL_OUTPUT_ARGS = {
93182
93211
  init_errors();
93183
93212
 
93184
93213
  // scripts/akm-migrate/help.txt
93185
- var help_default = `Usage: akm-migrate <command> [options]
93186
-
93187
- The one migration tool for an akm installation. Every historical shape akm
93188
- has ever written lives here; the CLI proper reads only current schemas.
93189
- \`status\` and \`apply\` run every step, in order, and print one combined JSON
93190
- plan (exit 1 when any step is blocked):
93191
-
93192
- 1. legacy config \`extraParams\` keys lifted onto first-class engine fields
93193
- 2. scheduler grants bound to the configured source installation that was
93194
- approved (stale grants whose bundle no longer exists are removed)
93195
- 3. pending state.db migrations, historical-destructive ones included,
93196
- with a verified sibling safety copy (the only path that admits them)
93197
- 4. task-v2 files to task v3, then task-v3 files to task source v4
93198
- 5. superseded pre-0.9.0 \`.akm\` residue and stale filesystem transactions
93199
- 6. live \`.akm\` writers relocated to \`$STATE\`/\`$CACHE\`, for every local
93200
- bundle (distill-rejected, eval-cases, measurement verdicts, and stale
93201
- improve-pipeline locks \u2014 a lock a live run still holds is left alone)
93202
-
93203
- \`akm migrate status|apply\` wraps this executable; \`akm upgrade\` runs
93204
- \`apply\` after its install step, so an image that ships akm can put either
93205
- in its entrypoint (a current installation is a no-op).
93206
-
93207
- Commands:
93208
- status Inspect every pending migration without changing anything.
93209
- apply [--dry-run] Back up and apply every pending migration.
93210
- `;
93214
+ var help_default = "Usage: akm-migrate <command> [options]\n\nThe one migration tool for an akm installation. Every historical shape akm\nhas ever written lives here; the CLI proper reads only current schemas.\n`status` and `apply` run every step, in order, and print one combined JSON\nplan (exit 1 when any step is blocked):\n\n 1. legacy config `extraParams` keys lifted onto first-class engine fields\n 2. retired `experimental.*` config keys removed (today `workflowEngine`)\n 3. scheduler grants bound to the configured source installation that was\n approved (stale grants whose bundle no longer exists are removed)\n 4. pending state.db migrations, historical-destructive ones included,\n with a verified sibling safety copy (the only path that admits them)\n 5. source-owned schedule enablement converted to host-local scheduler\n grants\n 6. task-v2 files to task v3, then task-v3 files to task source v4\n 7. superseded pre-0.9.0 `.akm` residue and stale filesystem transactions\n 8. live `.akm` writers relocated to `$STATE`/`$CACHE`, for every local\n bundle (distill-rejected, eval-cases, measurement verdicts, and stale\n improve-pipeline locks \u2014 a lock a live run still holds is left alone)\n\n`akm migrate status|apply` wraps this executable; `akm upgrade` runs\n`apply` after its install step, so an image that ships akm can put either\nin its entrypoint (a current installation is a no-op).\n\nCommands:\n status Inspect every pending migration without changing anything.\n apply [--dry-run] Back up and apply every pending migration.\n";
93211
93215
 
93212
93216
  // scripts/akm-migrate/run-migrate.ts
93213
93217
  init_common();
@@ -94743,10 +94747,44 @@ function applyConfigExtraParamsLift(configPath) {
94743
94747
  return { applied: true, lifted, conflicts: [] };
94744
94748
  }
94745
94749
 
94750
+ // scripts/akm-migrate/migrate/config-retired-experimental-keys.ts
94751
+ function readRawConfig2(configPath) {
94752
+ const text = readConfigText(configPath);
94753
+ if (text === undefined)
94754
+ return;
94755
+ return parseConfigText(text, configPath);
94756
+ }
94757
+ function findConfigRetiredExperimentalKeys(configPath) {
94758
+ const raw = readRawConfig2(configPath);
94759
+ if (!raw)
94760
+ return { removed: [] };
94761
+ return { removed: retiredExperimentalKeysIn(raw).map((key) => `experimental.${key}`) };
94762
+ }
94763
+ function applyConfigRetiredExperimentalKeys(configPath) {
94764
+ const raw = readRawConfig2(configPath);
94765
+ if (!raw)
94766
+ return { applied: false, removed: [] };
94767
+ const retired = retiredExperimentalKeysIn(raw);
94768
+ if (retired.length === 0)
94769
+ return { applied: false, removed: [] };
94770
+ const experimental = { ...raw.experimental };
94771
+ for (const key of retired)
94772
+ delete experimental[key];
94773
+ const config = { ...raw, experimental };
94774
+ const release = acquireConfigLock();
94775
+ try {
94776
+ backupExistingConfig(configPath);
94777
+ writeConfigAtomic(configPath, config);
94778
+ } finally {
94779
+ release();
94780
+ }
94781
+ return { applied: true, removed: retired.map((key) => `experimental.${key}`) };
94782
+ }
94783
+
94746
94784
  // scripts/akm-migrate/migrate/config-scheduler-source-ids.ts
94747
94785
  init_asset_ref();
94748
94786
  init_bundle_id();
94749
- function readRawConfig2(configPath) {
94787
+ function readRawConfig3(configPath) {
94750
94788
  const text = readConfigText(configPath);
94751
94789
  return text === undefined ? undefined : parseConfigText(text, configPath);
94752
94790
  }
@@ -94801,13 +94839,13 @@ function implicitBundleSourceId(raw, bundleId) {
94801
94839
  return implicitId === bundleId ? filesystemBundleSourceId(root2) : undefined;
94802
94840
  }
94803
94841
  function findConfigSchedulerSourceIdMigration(configPath) {
94804
- const raw = readRawConfig2(configPath);
94842
+ const raw = readRawConfig3(configPath);
94805
94843
  return Object.freeze({ changes: Object.freeze(raw ? migrationChanges(raw) : []) });
94806
94844
  }
94807
94845
  function applyConfigSchedulerSourceIdMigration(configPath) {
94808
94846
  const release = acquireConfigLock();
94809
94847
  try {
94810
- const raw = readRawConfig2(configPath);
94848
+ const raw = readRawConfig3(configPath);
94811
94849
  if (!raw)
94812
94850
  return Object.freeze({ applied: false, changes: Object.freeze([]) });
94813
94851
  const changes = migrationChanges(raw);
@@ -104027,12 +104065,16 @@ async function runMigration(options) {
104027
104065
  if (apply && configExtraParams.applied)
104028
104066
  resetConfigCache();
104029
104067
  const pendingLift = apply ? undefined : configExtraParams.pending;
104068
+ const configRetiredExperimentalKeys = apply ? applyConfigRetiredExperimentalKeys(configPath) : { pending: findConfigRetiredExperimentalKeys(configPath) };
104069
+ if (apply && configRetiredExperimentalKeys.applied)
104070
+ resetConfigCache();
104030
104071
  if (pendingLift && pendingLift.lifted.length > 0) {
104031
104072
  return {
104032
104073
  schemaVersion: 1,
104033
104074
  status: "blocked",
104034
104075
  blockers: pendingLift.lifted,
104035
104076
  configExtraParams,
104077
+ configRetiredExperimentalKeys,
104036
104078
  stateMigrations: { pending: listPendingStateMigrations() }
104037
104079
  };
104038
104080
  }
@@ -104047,6 +104089,7 @@ async function runMigration(options) {
104047
104089
  blockers: pendingSchedulerBindings.changes.map((change) => `${change.kind === "bind" ? "bind" : "drop"} scheduler activation ${change.ref}` + (change.reason ? `: ${change.reason}` : "")),
104048
104090
  configExtraParams,
104049
104091
  configSchedulerSourceIds,
104092
+ configRetiredExperimentalKeys,
104050
104093
  stateMigrations: { pending: listPendingStateMigrations() }
104051
104094
  };
104052
104095
  }
@@ -104066,12 +104109,14 @@ async function runMigration(options) {
104066
104109
  }
104067
104110
  const stateStatus = "pending" in stateMigrations && stateMigrations.pending.length > 0 ? "ready" : "current";
104068
104111
  const schedulerStatus = "pending" in schedulerActivation && schedulerActivation.pending.length > 0 ? "ready" : "current";
104112
+ const retiredKeysStatus = "pending" in configRetiredExperimentalKeys && configRetiredExperimentalKeys.pending.removed.length > 0 ? "ready" : "current";
104069
104113
  return {
104070
104114
  schemaVersion: 1,
104071
- status: worstStatus(worstStatus(worstStatus(taskV3.status, taskV4.status), stateStatus), schedulerStatus),
104115
+ status: worstStatus(worstStatus(worstStatus(worstStatus(taskV3.status, taskV4.status), stateStatus), schedulerStatus), retiredKeysStatus),
104072
104116
  blockers: [...taskV3.blockers, ...taskV4.blockers],
104073
104117
  configExtraParams,
104074
104118
  configSchedulerSourceIds,
104119
+ configRetiredExperimentalKeys,
104075
104120
  stateMigrations,
104076
104121
  schedulerActivation,
104077
104122
  taskV3Migration: taskV3.taskV3Migration,
@@ -299,6 +299,15 @@ async function compileDesiredSourceSet(input, collector) {
299
299
  async function compileTaskSources(input, collector, out, failures) {
300
300
  if (input.adapterId !== "akm" && input.adapterId !== "akm-task")
301
301
  return;
302
+ for (const symlink of collector.symlinkSources()) {
303
+ if (!isAuthoredTaskRelativePath(input.adapterId, symlink.relativePath))
304
+ continue;
305
+ const qualifiedRef = makeBundleRef(input.bundleName, symlink.relativePath.slice(0, -4));
306
+ if (input.enabledActivations && !input.enabledActivations.has(schedulerActivationKey("task", qualifiedRef))) {
307
+ continue;
308
+ }
309
+ failures.push(taskFailure(symlink.sourcePath, qualifiedRef, symbolicSourceError(symlink.sourcePath)));
310
+ }
302
311
  const physicalOwners = new Map();
303
312
  for (const guarded of collector.authoredTaskSources(input.adapterId)) {
304
313
  const sourcePath = guarded.sourcePath;
@@ -531,6 +540,26 @@ function enumerateWorkflowLookups(input, collector, failures) {
531
540
  owners.push(guarded);
532
541
  lookups.set(canonicalName, owners);
533
542
  }
543
+ for (const symlink of collector.symlinkSources()) {
544
+ if (!isAuthoredWorkflowRelativePath(input.adapterId, symlink.relativePath))
545
+ continue;
546
+ if (path.basename(symlink.sourcePath).toLowerCase() === "readme.md")
547
+ continue;
548
+ const authoredName = workflowNameForSourcePath(input.sourceRoot, input.adapterId, symlink.sourcePath);
549
+ if (authoredName === undefined)
550
+ continue;
551
+ const canonicalName = canonicalizeWorkflowName(authoredName);
552
+ // A real sibling sharing this name must not compile either: runtime
553
+ // resolution follows the symlink and may pick it over the file the
554
+ // binding was compiled from. The symbolic failure below is the ref's
555
+ // only report.
556
+ lookups.delete(canonicalName);
557
+ const failureRef = makeBundleRef(input.bundleName, input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName);
558
+ if (input.enabledActivations && !input.enabledActivations.has(schedulerActivationKey("workflow", failureRef))) {
559
+ continue;
560
+ }
561
+ failures.push(workflowFailure(symlink.sourcePath, failureRef, symbolicSourceError(symlink.sourcePath)));
562
+ }
534
563
  return new Map([...lookups]
535
564
  .sort(([left], [right]) => compareCodePoints(left, right))
536
565
  .map(([name, sources]) => [name, Object.freeze(sources.sort(compareGuardedSources))]));
@@ -596,6 +625,14 @@ function assertUniqueInstalledIds(installed) {
596
625
  seen.add(binding.id);
597
626
  }
598
627
  }
628
+ /**
629
+ * The reason recorded for a symlinked task/workflow source: it stays a
630
+ * per-source failure (like the read boundary it replaces), never a silent
631
+ * drop and never a follow.
632
+ */
633
+ function symbolicSourceError(sourcePath) {
634
+ return new UsageError(`${sourcePath} is a symbolic source; guarded reads require a regular no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
635
+ }
599
636
  function taskFailure(file, ref, cause) {
600
637
  const detail = taskSourceErrorDetail(cause);
601
638
  const reason = detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
@@ -629,6 +666,29 @@ export function assertSchedulerSourceSnapshot(snapshot) {
629
666
  throw new UsageError(`Scheduler desired source read set changed after projection; refusing native mutation: ${errorMessage(cause)}`, "RESOURCE_ALREADY_EXISTS");
630
667
  }
631
668
  }
669
+ /**
670
+ * Shared with the symlink classification in {@link compileTaskSources}: a
671
+ * `.yml` under `tasks/` (or, for `akm-task`, anywhere) is a task candidate —
672
+ * one classifier for both a captured file and an uncaptured symlink entry.
673
+ */
674
+ function isAuthoredTaskRelativePath(adapterId, relativePath) {
675
+ if (!relativePath.endsWith(".yml"))
676
+ return false;
677
+ if (adapterId === "akm-task")
678
+ return true;
679
+ return path.posix.dirname(relativePath) === "tasks";
680
+ }
681
+ /**
682
+ * Shared with the symlink classification in {@link enumerateWorkflowLookups}:
683
+ * anything under `workflows/` (or, for `akm-workflow`, anywhere) is a
684
+ * workflow candidate — one classifier for both a captured file and an
685
+ * uncaptured symlink entry.
686
+ */
687
+ function isAuthoredWorkflowRelativePath(adapterId, relativePath) {
688
+ if (adapterId === "akm-workflow")
689
+ return true;
690
+ return relativePath.startsWith("workflows/");
691
+ }
632
692
  class SchedulerSourceCollector {
633
693
  #adapterId;
634
694
  #sourceRoot;
@@ -637,6 +697,14 @@ class SchedulerSourceCollector {
637
697
  this.#adapterId = input.adapterId;
638
698
  this.#sourceRoot = path.resolve(input.sourceRoot);
639
699
  const root = this.#collector.trackDirectory(this.#sourceRoot, this.#sourceRoot);
700
+ if (input.adapterId === "akm") {
701
+ for (const scheduledName of ["tasks", "workflows"]) {
702
+ const entry = root.entries.find((candidate) => candidate.name === scheduledName);
703
+ if (entry?.kind === "symlink") {
704
+ throw new UsageError(`${path.join(this.#sourceRoot, scheduledName)} is a symbolic source with a physical source identity collision; guarded reads require one no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
705
+ }
706
+ }
707
+ }
640
708
  const rootDirectories = new Set(root.entries.filter((entry) => entry.kind === "directory").map((entry) => entry.name));
641
709
  const candidates = [];
642
710
  if (input.adapterId === "akm") {
@@ -669,27 +737,33 @@ class SchedulerSourceCollector {
669
737
  authoredTaskSources(adapterId) {
670
738
  return this.#collector
671
739
  .snapshot()
672
- .sources.filter((file) => {
673
- if (!file.authored || !file.relativePath.endsWith(".yml"))
674
- return false;
675
- if (adapterId === "akm-task")
676
- return true;
677
- return path.posix.dirname(file.relativePath) === "tasks";
678
- })
740
+ .sources.filter((file) => file.authored && isAuthoredTaskRelativePath(adapterId, file.relativePath))
679
741
  .sort(compareGuardedSources);
680
742
  }
681
743
  authoredWorkflowSources(adapterId) {
682
744
  return this.#collector
683
745
  .snapshot()
684
- .sources.filter((file) => {
685
- if (!file.authored)
686
- return false;
687
- if (adapterId === "akm-workflow")
688
- return true;
689
- return file.relativePath.startsWith("workflows/");
690
- })
746
+ .sources.filter((file) => file.authored && isAuthoredWorkflowRelativePath(adapterId, file.relativePath))
691
747
  .sort(compareGuardedSources);
692
748
  }
749
+ /**
750
+ * Every `kind: "symlink"` entry across the directory manifests captured so
751
+ * far (never read, never followed — see `captureGuardedDirectoryManifest`),
752
+ * with its path relative to the source root so callers can classify it
753
+ * with the same predicates as a real, captured file.
754
+ */
755
+ symlinkSources() {
756
+ const entries = [];
757
+ for (const manifest of this.#collector.snapshot().directoryManifests) {
758
+ for (const entry of manifest.entries) {
759
+ if (entry.kind !== "symlink")
760
+ continue;
761
+ const sourcePath = path.join(manifest.directoryPath, entry.name);
762
+ entries.push({ sourcePath, relativePath: toPosix(path.relative(this.#sourceRoot, sourcePath)) });
763
+ }
764
+ }
765
+ return entries;
766
+ }
693
767
  readBytes(file, containmentRoot) {
694
768
  this.#trackAncestors(file, containmentRoot);
695
769
  return this.#collector.readBytes(file, containmentRoot);
@@ -1673,16 +1673,25 @@ in order:
1673
1673
 
1674
1674
  1. legacy config `extraParams` keys lifted onto first-class engine fields
1675
1675
  (`configExtraParams`);
1676
- 2. pending `state.db` migrations, historical-destructive ones included, with
1676
+ 2. retired `experimental.*` config keys removed, today `workflowEngine`
1677
+ (`configRetiredExperimentalKeys`) — config loading already ignores them
1678
+ with a one-time warning, so this only cleans the file;
1679
+ 3. scheduler grants bound to the configured source installation that was
1680
+ approved, with stale grants for removed bundles dropped
1681
+ (`configSchedulerSourceIds`);
1682
+ 4. pending `state.db` migrations, historical-destructive ones included, with
1677
1683
  a verified sibling safety copy (`stateMigrations`) — the only path besides
1678
1684
  `akm upgrade` that admits released migration 018, which an ordinary
1679
1685
  command refuses;
1680
- 3. task-v2 files to task v3, then task-v3 files to task source v4
1686
+ 5. source-owned schedule enablement converted to host-local scheduler grants
1687
+ (`schedulerActivation`);
1688
+ 6. task-v2 files to task v3, then task-v3 files to task source v4
1681
1689
  (`taskV3Migration`, `taskV4Migration`), each keeping its own lock, backup,
1682
1690
  prevalidation, and rollback, so a file blocked in the first generation does
1683
1691
  not stop the second from converting files already at `version: 3`;
1684
- 4. superseded pre-0.9.0 `.akm` residue and stale filesystem transactions
1685
- (`deadResidue`, `staleTxns`).
1692
+ 7. superseded pre-0.9.0 `.akm` residue and stale filesystem transactions
1693
+ (`deadResidue`, `staleTxns`), then live `.akm` writers relocated to
1694
+ `$STATE`/`$CACHE` (`writerRelocation`).
1686
1695
 
1687
1696
  ```sh
1688
1697
  akm migrate status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.17-alpha.1",
3
+ "version": "0.9.17-alpha.3",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [