akm-cli 0.9.15 → 0.9.16-alpha.2

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 (86) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  4. package/dist/cli/retired-commands.js +0 -2
  5. package/dist/commands/env/env-binding.js +4 -4
  6. package/dist/commands/env/env-cli.js +3 -3
  7. package/dist/commands/improve/improve-cli.js +19 -14
  8. package/dist/commands/improve/reflect.js +23 -2
  9. package/dist/commands/lint/base-linter.js +9 -0
  10. package/dist/commands/lint/env-key-rules.js +2 -2
  11. package/dist/commands/proposal/propose.js +15 -1
  12. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  13. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  14. package/dist/commands/read/search.js +33 -4
  15. package/dist/commands/read/show.js +21 -2
  16. package/dist/commands/registry-cli.js +5 -5
  17. package/dist/commands/sources/add-cli.js +59 -16
  18. package/dist/commands/sources/bundle-cli.js +35 -11
  19. package/dist/commands/sources/bundle-config-ops.js +30 -0
  20. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  21. package/dist/commands/sources/installed-stashes.js +43 -28
  22. package/dist/commands/sources/source-add.js +33 -17
  23. package/dist/commands/sources/source-manage.js +34 -12
  24. package/dist/commands/sources/stash-skeleton.js +6 -3
  25. package/dist/commands/tasks/explain.js +4 -1
  26. package/dist/commands/tasks/tasks-cli.js +31 -9
  27. package/dist/commands/tasks/tasks.js +239 -194
  28. package/dist/commands/tasks/validate.js +20 -32
  29. package/dist/core/activation-policy.js +4 -4
  30. package/dist/core/adapter/adapters/akm-adapter.js +5 -0
  31. package/dist/core/adapter/execution-source.js +10 -29
  32. package/dist/core/config/config-schema.js +64 -8
  33. package/dist/core/config/config-sources.js +96 -2
  34. package/dist/core/config/config.js +190 -24
  35. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  36. package/dist/core/config/schema/execution.js +23 -0
  37. package/dist/core/config/schema/experimental.js +1 -1
  38. package/dist/core/config/schema/scheduler.js +20 -0
  39. package/dist/core/config/schema/search.js +1 -1
  40. package/dist/core/config/schema/sources-bundles.js +32 -1
  41. package/dist/core/content-safety.js +52 -0
  42. package/dist/core/maintenance-barrier.js +6 -6
  43. package/dist/core/type-presentation.js +1 -1
  44. package/dist/core/write-source.js +13 -8
  45. package/dist/indexer/bundle-identity-guard.js +45 -8
  46. package/dist/indexer/indexer.js +1 -1
  47. package/dist/indexer/materialize-embeddings.js +15 -1
  48. package/dist/indexer/search/search-source.js +29 -11
  49. package/dist/integrations/agent/execution-lowering.js +3 -2
  50. package/dist/integrations/agent/execution-preparation.js +32 -1
  51. package/dist/integrations/agent/prompts.js +1 -1
  52. package/dist/integrations/agent/request-lowering.js +3 -2
  53. package/dist/llm/client.js +2 -1
  54. package/dist/output/shapes/passthrough.js +2 -0
  55. package/dist/registry/resolve.js +37 -10
  56. package/dist/scripts/akm-migrate-node.js +13644 -9894
  57. package/dist/scripts/akm-migrate.js +12626 -8876
  58. package/dist/setup/setup.js +3 -3
  59. package/dist/setup/steps/tasks.js +29 -36
  60. package/dist/sources/providers/git-install.js +17 -11
  61. package/dist/sources/providers/git-provider.js +12 -5
  62. package/dist/sources/providers/git-stash.js +38 -16
  63. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  64. package/dist/storage/repositories/index-vec-repository.js +100 -0
  65. package/dist/tasks/activation-config.js +90 -0
  66. package/dist/tasks/backends/cron.js +9 -0
  67. package/dist/tasks/backends/launchd.js +1 -0
  68. package/dist/tasks/backends/schtasks.js +2 -0
  69. package/dist/tasks/embedded.js +4 -5
  70. package/dist/tasks/scheduler-binding.js +2 -2
  71. package/dist/tasks/scheduler-sync-preview.js +8 -1
  72. package/dist/tasks/scheduler-sync.js +19 -10
  73. package/dist/tasks/source/parse-task-source.js +10 -113
  74. package/dist/tasks/source/project-v4.js +2 -2
  75. package/dist/tasks/source/task-source-v4.js +4 -12
  76. package/dist/tasks/source/task-to-v3.js +4 -12
  77. package/dist/tasks/source/task-to-v4.js +40 -7
  78. package/docs/migration/README.md +1 -0
  79. package/docs/migration/release-notes/0.9.16.md +72 -0
  80. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  81. package/docs/reference/cli.md +37 -29
  82. package/docs/reference/configuration.md +48 -5
  83. package/docs/reference/tasks.md +34 -29
  84. package/package.json +1 -1
  85. package/schemas/akm-config.json +112 -4
  86. package/schemas/akm-task.json +1 -2
@@ -9,23 +9,18 @@
9
9
  * a bundle/adapter/concept id at all — it reads exactly the path it was
10
10
  * given and classifies it.
11
11
  *
12
- * Reuses the exact version-routing shim `parseTaskSource`
13
- * (`src/tasks/source/parse-task-source.ts`) already applies for every other
14
- * task-source reader (`akm task sync`'s `compileTaskSources` included) —
15
- * this module never forks a second parser or a second v2/v3 migration
16
- * planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
17
- * the SAME front-end helpers that shim itself calls first; they are used
18
- * here only to recover the file's ORIGINALLY DECLARED schema version for
19
- * the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
20
- * always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
21
- * its own once a v2/v3 source has been converted in memory.
12
+ * Reuses the exact current-schema router `parseTaskSource`
13
+ * (`src/tasks/source/parse-task-source.ts`) used by every other task-source
14
+ * reader. Historical v2/v3 documents are deliberately rejected here and
15
+ * point to `akm migrate apply`; validation does not embed a second legacy
16
+ * parser or perform an in-memory migration.
22
17
  *
23
18
  * Beyond parsing, this module also runs the SAME two per-source gates
24
19
  * `akm task sync`'s `compileTaskSources` runs before it ever installs a
25
20
  * schedule — `assertTaskScheduleInputsSatisfyContract` and
26
21
  * `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
27
22
  * exactly this reuse) — so a file `sync` would reject can never
28
- * be reported `valid`/`converts` here. Cron dialect is checked against
23
+ * be reported `valid` here. Cron dialect is checked against
29
24
  * `backendNameForPlatform()`, the same platform default `sync` falls back
30
25
  * to whenever it has no injected/native-inspected backend to hand (see
31
26
  * `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
@@ -48,19 +43,12 @@
48
43
  * table in its header, extended for the two gates above):
49
44
  * - `valid` — parses as task source v4 directly (declared `version: 4`)
50
45
  * and passes both sync gates.
51
- * - `converts` — declared `version: 2` or `3`; the deterministic
52
- * in-memory migrator produced a valid v4 document that
53
- * passes both sync gates.
54
- * - `blocked` — declared `version: 2` or `3`; the migrator itself
55
- * could not convert it (an ambiguous/unmigratable
56
- * shape) — the ONLY way `parseTaskSource` ever throws
57
- * for those two version numbers, so no message-text
58
- * sniffing is needed to tell this apart from `invalid`.
46
+ * - `blocked` — declared `version: 2` or `3`; runtime does not accept
47
+ * it and the explicit migrator must rewrite it first.
59
48
  * - `invalid` — the document declares SOME version (`4`, or anything
60
49
  * other than 2/3/4) but fails to parse/validate, OR it
61
- * parsed (directly or via a SUCCESSFUL v2/v3
62
- * conversion) but fails one of the two sync gates
63
- * above, OR the YAML itself does not parse at all
50
+ * parsed but fails one of the two sync gates above, OR
51
+ * the YAML itself does not parse at all
64
52
  * (a genuine syntax error, not merely a non-task
65
53
  * shape) — reported with the parser's own reason.
66
54
  * - `not-a-task` — the document parses as YAML but never declares a
@@ -138,11 +126,14 @@ export async function akmTaskValidate(filePath) {
138
126
  if (!(cause instanceof UsageError))
139
127
  throw cause;
140
128
  const reason = cause.message;
141
- // `parseTaskSource` only ever throws for a declared version 2/3 via the
142
- // unmigratable-conversion branch (see this file's header) — no separate
143
- // message check needed to recognize "blocked" here.
144
129
  if (declaredVersion === 2 || declaredVersion === 3) {
145
- return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
130
+ return {
131
+ ok: false,
132
+ path: resolvedPath,
133
+ sourceVersion: declaredVersion,
134
+ outcome: "blocked",
135
+ reason: `${reason} Run \`akm migrate apply --dry-run\`, review the plan, then run \`akm migrate apply\`.`,
136
+ };
146
137
  }
147
138
  if (peekFailed) {
148
139
  return { ok: false, path: resolvedPath, outcome: "invalid", reason };
@@ -161,11 +152,8 @@ export async function akmTaskValidate(filePath) {
161
152
  // Success is unreachable from any path that leaves `declaredVersion`
162
153
  // undefined — the router requires a numeric 2/3/4 version to reach here.
163
154
  const sourceVersion = declaredVersion ?? 4;
164
- // The document itself parsed (directly, or via a successful v2/v3
165
- // conversion) — now the two gates `compileTaskSources` runs before
166
- // accepting it. A violation here is `invalid`, never `blocked`: the
167
- // migrator already succeeded, so this is the same kind of defect a
168
- // native v4 document with the identical schedule would have.
155
+ // The current document parsed; now run the two gates `compileTaskSources`
156
+ // applies before accepting it.
169
157
  try {
170
158
  assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
171
159
  assertTaskScheduleCronValid(parsed.v4, backend);
@@ -180,7 +168,7 @@ export async function akmTaskValidate(filePath) {
180
168
  ok: true,
181
169
  path: resolvedPath,
182
170
  sourceVersion,
183
- outcome: sourceVersion === 2 || sourceVersion === 3 ? "converts" : "valid",
171
+ outcome: "valid",
184
172
  resolved: buildResolved(id, parsed.v4),
185
173
  };
186
174
  }
@@ -22,7 +22,7 @@ const INTERACTIVE_TOOL_ENV_KEYS = new Set(["EDITOR", "VISUAL", "PAGER"]);
22
22
  * genuine RCE-class key; first-party stashes warn. The interactive-tool
23
23
  * group (see {@link INTERACTIVE_TOOL_ENV_KEYS}) only ever warns, since akm's
24
24
  * own env-injection path never invokes those keys as a command. An explicit
25
- * `--allow-insecure` (threaded through by the caller, same override
25
+ * `--allow-dangerous-env-keys` (threaded through by the caller, same override
26
26
  * `decideDangerousKeyInstall`'s `"warn-allow"` already honors for rule 2)
27
27
  * downgrades a remaining block to a warning too — the operator is not racing
28
28
  * themselves. See rule 1 above.
@@ -31,7 +31,7 @@ const INTERACTIVE_TOOL_ENV_KEYS = new Set(["EDITOR", "VISUAL", "PAGER"]);
31
31
  * (already filtered by the caller via `isDangerousEnvKey`).
32
32
  * @param thirdParty `true` when the env's source is a third-party stash — i.e.
33
33
  * its origin carries a `registryId`.
34
- * @param allowInsecure `true` when the operator passed `--allow-insecure` (or
34
+ * @param allowDangerousEnvKeys `true` when the operator passed `--allow-dangerous-env-keys` (or
35
35
  * its equivalent) for this injection. Defaults to `false`.
36
36
  */
37
37
  export function decideDangerousEnvInjection(input) {
@@ -39,7 +39,7 @@ export function decideDangerousEnvInjection(input) {
39
39
  return "allow";
40
40
  if (!input.thirdParty)
41
41
  return "warn";
42
- if (input.allowInsecure)
42
+ if (input.allowDangerousEnvKeys)
43
43
  return "warn";
44
44
  const onlyInteractiveTool = input.dangerousKeys.every((key) => INTERACTIVE_TOOL_ENV_KEYS.has(key));
45
45
  return onlyInteractiveTool ? "warn" : "block";
@@ -53,7 +53,7 @@ export function decideDangerousEnvInjection(input) {
53
53
  export function decideDangerousKeyInstall(input) {
54
54
  if (!input.findingsPresent)
55
55
  return "allow";
56
- return input.allowInsecure ? "warn-allow" : "gate";
56
+ return input.allowDangerousEnvKeys ? "warn-allow" : "gate";
57
57
  }
58
58
  // ── Rule 3: write activation (search-source.ts, installations.ts) ────────────
59
59
  /**
@@ -130,6 +130,11 @@ function akmStashAbstains(root, absPath) {
130
130
  const segments = relPath.split(/[\\/]+/).filter(Boolean);
131
131
  if (segments.length === 0)
132
132
  return false;
133
+ // A bundle's root README describes the bundle itself; it is not an AKM
134
+ // knowledge asset. Nested README files remain adapter-owned documentation
135
+ // and are handled by the normal typed-directory matcher policy.
136
+ if (segments.length === 1 && segments[0]?.toLowerCase() === "readme.md")
137
+ return true;
133
138
  // Skip env `.env` files that have a sibling `.sensitive` marker file.
134
139
  if (segments[0] === "env" && (absPath.endsWith(".env") || path.basename(absPath) === ".env")) {
135
140
  if (fs.existsSync(absPath.replace(/\.env$/, ".sensitive")))
@@ -127,15 +127,6 @@ function nullableString(value, path) {
127
127
  function nullableObject(value, path) {
128
128
  return value === null ? null : cloneExecutionJsonObject(value, path);
129
129
  }
130
- function nullableEnvironment(value, path) {
131
- if (value === null)
132
- return null;
133
- const environment = cloneExecutionJsonObject(value, path);
134
- if (Object.values(environment).some((entry) => typeof entry !== "string")) {
135
- throw new TypeError(`${path} values must be strings`);
136
- }
137
- return environment;
138
- }
139
130
  function nullableTimeout(value, path) {
140
131
  const timeout = cloneExecutionJson(value, path);
141
132
  if (timeout !== null && typeof timeout !== "string" && typeof timeout !== "number") {
@@ -167,6 +158,16 @@ export function executionDefaultsFromFrontmatter(data, options) {
167
158
  const namespace = own(frontmatter, "akm")
168
159
  ? requireMetadataMapping(frontmatter.akm, "frontmatter.akm")
169
160
  : snapshotStrictRecord({}, "frontmatter.akm");
161
+ for (const key of ["workspace", "environment", "runtime"]) {
162
+ const location = own(frontmatter, key)
163
+ ? `frontmatter.${key}`
164
+ : own(namespace, key)
165
+ ? `frontmatter.akm.${key}`
166
+ : undefined;
167
+ if (location) {
168
+ throw new TypeError(`${location} is host-controlled and cannot be declared by an executable asset; configure the selected engine or workflow execution environment instead`);
169
+ }
170
+ }
170
171
  const out = Object.create(null);
171
172
  if (kind === "command" && own(frontmatter, "agent")) {
172
173
  out.agent = nullableString(frontmatter.agent, "frontmatter.agent");
@@ -269,26 +270,6 @@ export function executionDefaultsFromFrontmatter(data, options) {
269
270
  ? topLevelTimeouts.get(selectedTimeoutKey)
270
271
  : namespacedTimeouts.get(selectedTimeoutKey);
271
272
  }
272
- for (const key of ["workspace", "environment", "runtime"]) {
273
- const namespaced = own(namespace, key)
274
- ? key === "workspace"
275
- ? nullableString(namespace[key], `frontmatter.akm.${key}`)
276
- : key === "environment"
277
- ? nullableEnvironment(namespace[key], `frontmatter.akm.${key}`)
278
- : nullableObject(namespace[key], `frontmatter.akm.${key}`)
279
- : undefined;
280
- const topLevel = allowTopLevelEngine && own(frontmatter, key)
281
- ? key === "workspace"
282
- ? nullableString(frontmatter[key], `frontmatter.${key}`)
283
- : key === "environment"
284
- ? nullableEnvironment(frontmatter[key], `frontmatter.${key}`)
285
- : nullableObject(frontmatter[key], `frontmatter.${key}`)
286
- : undefined;
287
- if (topLevel !== undefined)
288
- out[key] = topLevel;
289
- else if (namespaced !== undefined)
290
- out[key] = namespaced;
291
- }
292
273
  return out;
293
274
  }
294
275
  function snapshotRendererInput(input) {
@@ -29,28 +29,29 @@
29
29
  * typo in an optional section.
30
30
  * - Unsupported top-level source shapes and provider kinds are hard-rejected;
31
31
  * silently dropping them would mask user data loss.
32
- * - UNKNOWN-KEY POLICY: object schemas use passthrough (unknown keys are
33
- * preserved and ignored, NOT rejected). akm runs across multiple installed
34
- * versions sharing one config.json; a newer version writes keys an older
35
- * version's schema doesn't know yet, so hard-rejecting unknown keys turned
36
- * benign version skew into `INVALID_CONFIG_FILE` failures. Known keys are
37
- * still type-checked; passthrough preserves unknown keys across a
38
- * load→save round trip so an older reader never strips a newer writer's
39
- * settings. (Replaced the prior strict-mode object walls.)
32
+ * - UNKNOWN-KEY POLICY: portable descriptive sections generally use
33
+ * passthrough so version skew round-trips newer settings. Small finite
34
+ * authority/toggle sections (`scheduler`, `execution`, `experimental`, and
35
+ * reranker policy) are strict: a misspelling there must not silently turn a
36
+ * safety decision off. The top level remains passthrough and warns on
37
+ * unknown keys.
40
38
  * - `defaultWriteTarget` resolution and similar cross-field invariants are
41
39
  * enforced at save time via `superRefine` on the top-level schema.
42
40
  */
43
41
  import { z } from "zod";
42
+ import { bundleRefToString, parseBundleRef } from "../asset/asset-ref.js";
44
43
  import { warnOnce } from "../warn.js";
45
44
  import { BUILTIN_IMPROVE_STRATEGY_NAMES, IMPROVE_PROCESS_ENGINE_CAPABILITIES } from "./engine-semantics.js";
46
45
  import { EmbeddingConnectionConfigSchema } from "./schema/embedding.js";
47
46
  import { EnginesSchema } from "./schema/engines.js";
47
+ import { ExecutionPolicyConfigSchema } from "./schema/execution.js";
48
48
  import { ExperimentalConfigSchema } from "./schema/experimental.js";
49
49
  import { FeedbackConfigSchema } from "./schema/feedback.js";
50
50
  import { ImproveConfigSchema } from "./schema/improve.js";
51
51
  import { IndexConfigSchema } from "./schema/index-config.js";
52
52
  import { OutputConfigSchema } from "./schema/output.js";
53
53
  import { CURRENT_CONFIG_VERSION, engineName, nonEmptyString, nonNegativeNumber } from "./schema/primitives.js";
54
+ import { SchedulerConfigSchema } from "./schema/scheduler.js";
54
55
  import { SearchConfigSchema } from "./schema/search.js";
55
56
  import { SetupConfigSchema } from "./schema/setup.js";
56
57
  import { BundlesConfigSchema, RegistryConfigEntrySchema } from "./schema/sources-bundles.js";
@@ -65,6 +66,7 @@ export { ConsolidateProcessConfigSchema, DistillProcessConfigSchema, ExtractProc
65
66
  export { IndexConfigSchema, IndexPassConfigSchema } from "./schema/index-config.js";
66
67
  export { OutputConfigSchema } from "./schema/output.js";
67
68
  export { CURRENT_CONFIG_VERSION, LlmInvocationOverridesSchema } from "./schema/primitives.js";
69
+ export { SchedulerActivationSchema, SchedulerConfigSchema } from "./schema/scheduler.js";
68
70
  export { SearchConfigSchema } from "./schema/search.js";
69
71
  export { SetupConfigSchema } from "./schema/setup.js";
70
72
  export { BundleConfigEntrySchema, BundlesConfigSchema, RegistryConfigEntrySchema, SourceConfigEntrySchema, } from "./schema/sources-bundles.js";
@@ -107,6 +109,8 @@ export const AkmConfigShape = {
107
109
  defaults: DefaultsSchema.optional(),
108
110
  semanticSearchMode: z.enum(["off", "auto"]).default("off"),
109
111
  embedding: EmbeddingConnectionConfigSchema.optional(),
112
+ // Host-local execution authority. Inherited layers are stripped.
113
+ execution: ExecutionPolicyConfigSchema.optional(),
110
114
  index: IndexConfigSchema.optional(),
111
115
  registries: z.array(RegistryConfigEntrySchema).optional(),
112
116
  // `bundles` + `defaultBundle` are the only source configuration shape. The
@@ -122,6 +126,9 @@ export const AkmConfigShape = {
122
126
  archiveRetentionDays: nonNegativeNumber.optional(),
123
127
  improve: ImproveConfigSchema.optional(),
124
128
  workflow: WorkflowConfigSchema.optional(),
129
+ // Host-local scheduler grants. Inherited layers are stripped by
130
+ // `resolveExtendsChain`; bundle-provided config never activates code.
131
+ scheduler: SchedulerConfigSchema.optional(),
125
132
  setup: SetupConfigSchema.optional(),
126
133
  // D8 — explicit opt-ins for behaviour outside the stability contract. Every
127
134
  // key defaults to OFF; see `src/core/config/experimental.ts` for the readers.
@@ -168,6 +175,55 @@ export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) =>
168
175
  message: `defaultBundle "${config.defaultBundle}" does not name a configured bundle`,
169
176
  });
170
177
  }
178
+ else if (config.bundles[config.defaultBundle]?.enabled === false) {
179
+ ctx.addIssue({
180
+ code: z.ZodIssueCode.custom,
181
+ path: ["defaultBundle"],
182
+ message: `defaultBundle "${config.defaultBundle}" is disabled`,
183
+ });
184
+ }
185
+ }
186
+ if (config.defaultWriteTarget !== undefined) {
187
+ const target = config.bundles?.[config.defaultWriteTarget];
188
+ if (!target) {
189
+ ctx.addIssue({
190
+ code: z.ZodIssueCode.custom,
191
+ path: ["defaultWriteTarget"],
192
+ message: `defaultWriteTarget "${config.defaultWriteTarget}" does not name a configured bundle`,
193
+ });
194
+ }
195
+ else if (target.enabled === false) {
196
+ ctx.addIssue({
197
+ code: z.ZodIssueCode.custom,
198
+ path: ["defaultWriteTarget"],
199
+ message: `defaultWriteTarget "${config.defaultWriteTarget}" is disabled`,
200
+ });
201
+ }
202
+ }
203
+ const activationKeys = new Set();
204
+ for (const [index, activation] of (config.scheduler?.enabled ?? []).entries()) {
205
+ try {
206
+ const parsed = parseBundleRef(activation.ref);
207
+ if (!parsed.bundle || parsed.fragment !== undefined || bundleRefToString(parsed) !== activation.ref) {
208
+ throw new Error("not canonical");
209
+ }
210
+ const key = `${activation.kind}\0${activation.ref}`;
211
+ if (activationKeys.has(key)) {
212
+ ctx.addIssue({
213
+ code: z.ZodIssueCode.custom,
214
+ path: ["scheduler", "enabled", index],
215
+ message: "duplicates an earlier scheduler activation",
216
+ });
217
+ }
218
+ activationKeys.add(key);
219
+ }
220
+ catch {
221
+ ctx.addIssue({
222
+ code: z.ZodIssueCode.custom,
223
+ path: ["scheduler", "enabled", index, "ref"],
224
+ message: "must be one canonical fully-qualified ref without a fragment",
225
+ });
226
+ }
171
227
  }
172
228
  for (const key of ["llm", "agent", "improve"]) {
173
229
  if (config.defaults && key in config.defaults) {
@@ -6,6 +6,7 @@
6
6
  * values from the current bundle map in an {@link AkmConfig}.
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
+ import fs from "node:fs";
9
10
  import path from "node:path";
10
11
  import { ConfigError } from "../errors.js";
11
12
  /** The current one-component-per-bundle configuration entry, if present. */
@@ -33,6 +34,87 @@ export function bundleComponentConfig(bundle) {
33
34
  export function bundleContentRoot(entryPath, componentRoot) {
34
35
  return path.resolve(entryPath, componentRoot ?? ".");
35
36
  }
37
+ /**
38
+ * Physical identity for an already materialized filesystem bundle.
39
+ *
40
+ * Configuration may spell the same directory through relative paths or
41
+ * symbolic links. Those spellings are not distinct sources: treating them as
42
+ * distinct would make ownership, default-source trust, and scheduler grants
43
+ * depend on syntax instead of the directory the OS will actually read.
44
+ */
45
+ export function bundlePhysicalContentRoot(entryPath, componentRoot) {
46
+ const resolved = bundleContentRoot(entryPath, componentRoot);
47
+ try {
48
+ return fs.realpathSync.native(resolved);
49
+ }
50
+ catch (cause) {
51
+ // Cache-backed sources can be configured before they are materialized.
52
+ // Their source descriptor remains the identity until a real path exists.
53
+ if (cause.code === "ENOENT")
54
+ return resolved;
55
+ throw new ConfigError(`Unable to resolve physical bundle root ${JSON.stringify(resolved)}: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_CONFIG_FILE");
56
+ }
57
+ }
58
+ /** Whether a configured bundle participates in reads, writes, and execution. */
59
+ export function isBundleEnabled(config, bundleId) {
60
+ const bundle = config.bundles?.[bundleId];
61
+ return bundle !== undefined && bundle.enabled !== false;
62
+ }
63
+ /**
64
+ * Stable identity of the origin currently installed under a bundle id.
65
+ *
66
+ * This deliberately excludes mutable policy (`enabled`, `writable`,
67
+ * credentials, adapter selection) and bundle contents. Ordinary updates and
68
+ * adapter auto-detection for the same origin keep their grant; changing the
69
+ * locator or component root does not.
70
+ */
71
+ export function bundleSourceId(config, bundleId) {
72
+ const bundle = config.bundles?.[bundleId];
73
+ if (!bundle) {
74
+ throw new ConfigError(`Bundle ${JSON.stringify(bundleId)} is not configured.`, "INVALID_CONFIG_FILE");
75
+ }
76
+ const component = bundleComponentConfig(bundle);
77
+ let source;
78
+ if (bundle.path !== undefined) {
79
+ source = { kind: "filesystem", locator: bundlePhysicalContentRoot(bundle.path, component?.root) };
80
+ }
81
+ else if (bundle.git !== undefined) {
82
+ source = { kind: "git", locator: normalizeInstalledGitRef("git", bundle.git) };
83
+ }
84
+ else if (bundle.website !== undefined) {
85
+ // Crawl/refresh settings are mutable fetch policy, not source identity.
86
+ // Changing them must not silently revoke a user's scheduling decision for
87
+ // the same website origin.
88
+ source = { kind: "website", url: bundle.website.url };
89
+ }
90
+ else if (bundle.npm !== undefined) {
91
+ source = { kind: "npm", locator: bundle.npm };
92
+ }
93
+ else {
94
+ throw new ConfigError(`Bundle ${JSON.stringify(bundleId)} has no source descriptor.`, "INVALID_CONFIG_FILE");
95
+ }
96
+ return hashBundleSourceIdentity({
97
+ version: 1,
98
+ source,
99
+ registryId: bundle.registryId ?? null,
100
+ componentRoot: component?.root ?? ".",
101
+ });
102
+ }
103
+ /** Identity for an environment-only filesystem bundle not persisted in config. */
104
+ export function filesystemBundleSourceId(contentRoot) {
105
+ return hashBundleSourceIdentity({
106
+ version: 1,
107
+ source: { kind: "filesystem", locator: bundlePhysicalContentRoot(contentRoot) },
108
+ registryId: null,
109
+ componentRoot: ".",
110
+ });
111
+ }
112
+ function hashBundleSourceIdentity(identity) {
113
+ return `sha256:${createHash("sha256")
114
+ .update("akm.bundle-source\0v1\0")
115
+ .update(JSON.stringify(identity))
116
+ .digest("hex")}`;
117
+ }
36
118
  /**
37
119
  * The resolved primary stash path — the `defaultBundle`'s filesystem `path`
38
120
  * (spec §10.1) — or `undefined` when no filesystem primary is configured.
@@ -68,13 +150,20 @@ export function bundleContentRoots(config) {
68
150
  for (const [id, entry] of Object.entries(bundles)) {
69
151
  if (typeof entry.path !== "string" || entry.path.length === 0)
70
152
  continue;
71
- out.push({ id, contentRoot: bundleContentRoot(entry.path, bundleComponentConfig(entry)?.root) });
153
+ out.push({ id, contentRoot: bundlePhysicalContentRoot(entry.path, bundleComponentConfig(entry)?.root) });
72
154
  }
73
155
  return out;
74
156
  }
75
157
  /** The bundle id whose resolved content root already matches `resolvedContentRoot`, if any. */
76
158
  export function bundleKeyForContentRoot(config, resolvedContentRoot) {
77
- return bundleContentRoots(config).find((entry) => entry.contentRoot === resolvedContentRoot)?.id;
159
+ let physical = path.resolve(resolvedContentRoot);
160
+ try {
161
+ physical = fs.realpathSync.native(physical);
162
+ }
163
+ catch {
164
+ // Compare the unresolved absolute locator when the candidate is not yet materialized.
165
+ }
166
+ return bundleContentRoots(config).find((entry) => entry.contentRoot === physical)?.id;
78
167
  }
79
168
  export function bundlesToSourceEntries(config) {
80
169
  const bundles = config.bundles;
@@ -96,6 +185,7 @@ export function bundleEntryToSourceEntry(key, bundle, isPrimary = false) {
96
185
  const base = {
97
186
  name: key,
98
187
  ...(bundle.writable !== undefined ? { writable: bundle.writable } : {}),
188
+ ...(bundle.credential !== undefined ? { credential: bundle.credential } : {}),
99
189
  ...(bundle.enabled !== undefined ? { enabled: bundle.enabled } : {}),
100
190
  ...(isPrimary ? { primary: true } : {}),
101
191
  };
@@ -235,6 +325,10 @@ export function resolveConfiguredSources(config) {
235
325
  }
236
326
  return out;
237
327
  }
328
+ /** Active sources in the same deterministic order as `resolveConfiguredSources`. */
329
+ export function resolveActiveConfiguredSources(config) {
330
+ return resolveConfiguredSources(config).filter((source) => source.enabled !== false);
331
+ }
238
332
  function toConfiguredSource(persisted, isPrimary) {
239
333
  const source = parseSourceSpec(persisted);
240
334
  if (!source)