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
@@ -11,8 +11,8 @@ import { ConfigError } from "../errors.js";
11
11
  import { liftLegacyEngineExtraParams } from "../extra-params.js";
12
12
  import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
13
13
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
14
- import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
15
- import { bundleComponentConfig, bundleContentRoot, bundlesToSourceEntries } from "./config-sources.js";
14
+ import { AkmConfigSchema, CURRENT_CONFIG_VERSION, listTopLevelConfigKeys } from "./config-schema.js";
15
+ import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundlesToSourceEntries } from "./config-sources.js";
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";
@@ -192,21 +192,60 @@ function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
192
192
  * keeps around, instead of only getting the final merged `AkmConfig` back.
193
193
  */
194
194
  function buildEffectiveConfig(liftedLocalRaw, sourcePath) {
195
+ warnUnknownTopLevelConfigKeys(liftedLocalRaw, sourcePath);
195
196
  const withExtends = resolveExtendsChain(liftedLocalRaw, sourcePath);
196
197
  const where = sourcePath ? ` at ${sourcePath}` : "";
197
198
  const parsed = AkmConfigSchema.safeParse(withExtends);
198
199
  if (!parsed.success) {
199
200
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
200
- throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
201
+ const needsSchedulerMigration = parsed.error.issues.some((issue) => issue.path[0] === "scheduler" && issue.path.at(-1) === "sourceId");
202
+ throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE", needsSchedulerMigration
203
+ ? "Run `akm migrate apply` to bind existing scheduler grants to their source."
204
+ : undefined);
201
205
  }
202
206
  const merged = deepMergeConfig(DEFAULT_CONFIG, parsed.data);
203
207
  const finalResult = AkmConfigSchema.safeParse(merged);
204
208
  if (!finalResult.success) {
205
209
  const lines = finalResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
206
- throw new ConfigError(`Invalid merged config${sourcePath ? ` at ${sourcePath}` : ""}:\n${lines}`, "INVALID_CONFIG_FILE");
210
+ const needsSchedulerMigration = finalResult.error.issues.some((issue) => issue.path[0] === "scheduler" && issue.path.at(-1) === "sourceId");
211
+ throw new ConfigError(`Invalid merged config${sourcePath ? ` at ${sourcePath}` : ""}:\n${lines}`, "INVALID_CONFIG_FILE", needsSchedulerMigration
212
+ ? "Run `akm migrate apply` to bind existing scheduler grants to their source."
213
+ : undefined);
207
214
  }
215
+ assertUniquePhysicalBundleRoots(finalResult.data, sourcePath);
208
216
  return finalResult.data;
209
217
  }
218
+ const RETIRED_TOP_LEVEL_CONFIG_KEYS = new Set([
219
+ "agent",
220
+ "bindings",
221
+ "features",
222
+ "installed",
223
+ "llm",
224
+ "modelAliases",
225
+ "profiles",
226
+ "sources",
227
+ "stashDir",
228
+ "stashes",
229
+ "writable",
230
+ ]);
231
+ function warnUnknownTopLevelConfigKeys(raw, sourcePath) {
232
+ const known = new Set(listTopLevelConfigKeys());
233
+ for (const key of Object.keys(raw).sort()) {
234
+ if (known.has(key) || RETIRED_TOP_LEVEL_CONFIG_KEYS.has(key))
235
+ continue;
236
+ warnOnce(`config:unknown-key:${sourcePath ?? "inline"}:${key}`, `Unknown config key ${JSON.stringify(key)}${sourcePath ? ` at ${sourcePath}` : ""} has no defined akm behavior. Check the spelling or remove it.`);
237
+ }
238
+ }
239
+ function assertUniquePhysicalBundleRoots(config, sourcePath) {
240
+ const owners = new Map();
241
+ for (const { id, contentRoot } of bundleContentRoots(config)) {
242
+ const prior = owners.get(contentRoot);
243
+ if (prior !== undefined) {
244
+ throw new ConfigError(`Invalid config${sourcePath ? ` at ${sourcePath}` : ""}: bundles ${JSON.stringify(prior)} and ${JSON.stringify(id)} resolve to the same physical content root ${contentRoot}.`, "INVALID_CONFIG_FILE", "Configure one bundle id per physical source root; symbolic-link aliases are not separate bundles.");
245
+ }
246
+ owners.set(contentRoot, id);
247
+ }
248
+ }
210
249
  /**
211
250
  * Parse raw config text and validate via Zod.
212
251
  * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
@@ -233,6 +272,7 @@ function collectExtendsLayers(localRaw, configPath) {
233
272
  const visited = new Set(configPath ? [path.resolve(configPath)] : []);
234
273
  let current = localRaw;
235
274
  let currentPath = configPath;
275
+ let containmentRoot;
236
276
  while (true) {
237
277
  const ref = current.extends;
238
278
  if (ref === undefined)
@@ -240,15 +280,18 @@ function collectExtendsLayers(localRaw, configPath) {
240
280
  if (typeof ref !== "string" || !ref.trim()) {
241
281
  throw new ConfigError(`Invalid "extends"${currentPath ? ` at ${currentPath}` : ""}: expected a non-empty string (a file path or bundle//path ref), got ${JSON.stringify(ref)}.`, "INVALID_CONFIG_FILE");
242
282
  }
243
- const { text, resolvedPath } = resolveConfigRefSource(ref, current, currentPath);
283
+ const resolved = resolveConfigRefSource(ref, current, currentPath, containmentRoot);
284
+ const { text, resolvedPath } = resolved;
244
285
  if (visited.has(resolvedPath)) {
245
286
  throw new ConfigError(`Config "extends" cycle detected: "${ref}"${currentPath ? ` (from ${currentPath})` : ""} resolves back to an already-visited config at ${resolvedPath}.`, "INVALID_CONFIG_FILE");
246
287
  }
247
288
  visited.add(resolvedPath);
248
289
  const baseRaw = runConfigFilePipeline(text, resolvedPath);
290
+ warnUnknownTopLevelConfigKeys(baseRaw, resolvedPath);
249
291
  layers.push({ ref, raw: baseRaw });
250
292
  current = baseRaw;
251
293
  currentPath = resolvedPath;
294
+ containmentRoot = resolved.containmentRoot;
252
295
  }
253
296
  }
254
297
  /**
@@ -274,10 +317,84 @@ function resolveExtendsChain(localRaw, configPath) {
274
317
  const layers = collectExtendsLayers(localRaw, configPath);
275
318
  let merged = {};
276
319
  for (let i = layers.length - 1; i >= 0; i--) {
277
- merged = deepMergeConfig(merged, layers[i].raw);
320
+ const layer = layers[i];
321
+ merged = deepMergeConfig(merged, i > 0 ? sanitizeInheritedConfig(layer.raw, layer.ref ?? String(i)) : layer.raw);
278
322
  }
279
323
  return merged;
280
324
  }
325
+ const HOST_LOCAL_CONFIG_KEYS = new Set([
326
+ "bundles",
327
+ "defaultBundle",
328
+ "defaultWriteTarget",
329
+ "embedding",
330
+ "execution",
331
+ "experimental",
332
+ "registries",
333
+ "scheduler",
334
+ "setup",
335
+ ]);
336
+ /**
337
+ * Shared config contributes portable behavior only. Source ownership,
338
+ * credentials, executable paths/arguments, and activation remain in the
339
+ * host's top-level config even when a bundle supplies the inherited file.
340
+ * LLM endpoints and model selection remain portable; their credentials never
341
+ * do.
342
+ */
343
+ function sanitizeInheritedConfig(raw, label) {
344
+ const inherited = { ...raw };
345
+ for (const key of HOST_LOCAL_CONFIG_KEYS) {
346
+ if (!Object.hasOwn(inherited, key))
347
+ continue;
348
+ delete inherited[key];
349
+ warnOnce(`config:inherited-host-local:${label}:${key}`, `Ignoring inherited config key ${JSON.stringify(key)} from ${label}; it is host-local and must be declared in the top-level config file.`);
350
+ }
351
+ if (isPlainObject(inherited.engines)) {
352
+ const engines = {};
353
+ let strippedAuthority = false;
354
+ for (const [name, engine] of Object.entries(inherited.engines)) {
355
+ if (!isPlainObject(engine)) {
356
+ engines[name] = engine;
357
+ continue;
358
+ }
359
+ const portable = { ...engine };
360
+ for (const key of ["apiKey", "apiKeyFile", "bin", "args", "workspace"]) {
361
+ if (!Object.hasOwn(portable, key))
362
+ continue;
363
+ delete portable[key];
364
+ strippedAuthority = true;
365
+ }
366
+ engines[name] = portable;
367
+ }
368
+ inherited.engines = engines;
369
+ if (strippedAuthority) {
370
+ warnOnce(`config:inherited-host-local:${label}:engines-authority`, `Ignoring inherited engine credentials, executable arguments, or workspace from ${label}; those fields are host-local.`);
371
+ }
372
+ }
373
+ if (isPlainObject(inherited.search) && Object.hasOwn(inherited.search, "curateRerank")) {
374
+ const { curateRerank: _curateRerank, ...portableSearch } = inherited.search;
375
+ inherited.search = portableSearch;
376
+ warnOnce(`config:inherited-host-local:${label}:search.curateRerank`, `Ignoring inherited config key "search.curateRerank" from ${label}; network endpoints and credentials are host-local.`);
377
+ }
378
+ if (isPlainObject(inherited.improve) && isPlainObject(inherited.improve.strategies)) {
379
+ const strategies = {};
380
+ let strippedSync = false;
381
+ for (const [name, profile] of Object.entries(inherited.improve.strategies)) {
382
+ if (isPlainObject(profile) && Object.hasOwn(profile, "sync")) {
383
+ const { sync: _sync, ...portableProfile } = profile;
384
+ strategies[name] = portableProfile;
385
+ strippedSync = true;
386
+ }
387
+ else {
388
+ strategies[name] = profile;
389
+ }
390
+ }
391
+ inherited.improve = { ...inherited.improve, strategies };
392
+ if (strippedSync) {
393
+ warnOnce(`config:inherited-host-local:${label}:improve.sync`, `Ignoring inherited improve strategy sync policy from ${label}; publication policy is host-local.`);
394
+ }
395
+ }
396
+ return inherited;
397
+ }
281
398
  /** `~` expands to the home directory, mirroring `apiKeyFile`'s resolution (engine-resolution.ts). */
282
399
  function expandExtendsHomePath(p) {
283
400
  return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
@@ -299,12 +416,12 @@ function looksLikeBundleAssetRef(ref) {
299
416
  * a missing file/bundle throws {@link ConfigError} naming the ref, as does an
300
417
  * empty, absolute, or content-root-escaping path after `//`.
301
418
  */
302
- function resolveConfigRefSource(ref, context, fromConfigPath) {
419
+ function resolveConfigRefSource(ref, context, fromConfigPath, containmentRoot) {
303
420
  return looksLikeBundleAssetRef(ref)
304
- ? resolveConfigBundleRefSource(ref, context)
305
- : resolveConfigFileRefSource(ref, fromConfigPath);
421
+ ? resolveConfigBundleRefSource(ref, context, containmentRoot)
422
+ : resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot);
306
423
  }
307
- function resolveConfigFileRefSource(ref, fromConfigPath) {
424
+ function resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot) {
308
425
  const expanded = expandExtendsHomePath(ref);
309
426
  let resolvedPath;
310
427
  if (path.isAbsolute(expanded)) {
@@ -316,13 +433,15 @@ function resolveConfigFileRefSource(ref, fromConfigPath) {
316
433
  else {
317
434
  throw new ConfigError(`extends "${ref}" is a relative path, but the current config has no known file location to resolve it against.`, "INVALID_CONFIG_FILE");
318
435
  }
436
+ if (containmentRoot !== undefined)
437
+ assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref);
319
438
  const text = readConfigText(resolvedPath);
320
439
  if (text === undefined) {
321
440
  throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist. Create the file first, or point "extends" at an existing config.`, "INVALID_CONFIG_FILE");
322
441
  }
323
- return { text, resolvedPath };
442
+ return { text, resolvedPath, ...(containmentRoot ? { containmentRoot } : {}) };
324
443
  }
325
- function resolveConfigBundleRefSource(ref, context) {
444
+ function resolveConfigBundleRefSource(ref, context, outerContainmentRoot) {
326
445
  // Split by hand rather than through `parseBundleRef`: the part after `//`
327
446
  // is a plain file path here, not an asset conceptId, so it must not be run
328
447
  // through conceptId validation (which, for instance, rejects every `..`
@@ -360,11 +479,31 @@ function resolveConfigBundleRefSource(ref, context) {
360
479
  if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
361
480
  throw new ConfigError(`extends "${ref}" escapes bundle "${bundleId}"'s content root.`, "INVALID_CONFIG_FILE");
362
481
  }
482
+ const containmentRoot = fs.realpathSync.native(bundleRoot);
483
+ assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref);
484
+ if (outerContainmentRoot !== undefined) {
485
+ assertConfigExtendsPhysicalContainment(outerContainmentRoot, resolvedPath, ref);
486
+ }
363
487
  const text = readConfigText(resolvedPath);
364
488
  if (text === undefined) {
365
489
  throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist locally. Sync the bundle first, or point "extends" at an existing file.`, "INVALID_CONFIG_FILE");
366
490
  }
367
- return { text, resolvedPath };
491
+ return { text, resolvedPath, containmentRoot: outerContainmentRoot ?? containmentRoot };
492
+ }
493
+ function assertConfigExtendsPhysicalContainment(root, candidate, ref) {
494
+ let physicalRoot;
495
+ let physicalCandidate;
496
+ try {
497
+ physicalRoot = fs.realpathSync.native(root);
498
+ physicalCandidate = fs.realpathSync.native(candidate);
499
+ }
500
+ catch (cause) {
501
+ throw new ConfigError(`Unable to verify physical containment for extends ${JSON.stringify(ref)}: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_CONFIG_FILE");
502
+ }
503
+ const relative = path.relative(physicalRoot, physicalCandidate);
504
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
505
+ throw new ConfigError(`extends ${JSON.stringify(ref)} resolves through a symbolic link outside its bundle content root.`, "INVALID_CONFIG_FILE");
506
+ }
368
507
  }
369
508
  /**
370
509
  * `akm config get --show-source` (#945): which raw layer the dotted path's
@@ -382,7 +521,8 @@ export function getConfigValueSource(dotted) {
382
521
  const liftedConfig = runConfigFilePipeline(text, configPath);
383
522
  const segments = dotted.split(".").filter((s) => s.length > 0);
384
523
  for (const layer of collectExtendsLayers(liftedConfig, configPath)) {
385
- if (hasRawPath(layer.raw, segments)) {
524
+ const effectiveLayer = layer.ref === undefined ? layer.raw : sanitizeInheritedConfig(layer.raw, layer.ref);
525
+ if (hasRawPath(effectiveLayer, segments)) {
386
526
  return layer.ref === undefined ? "local" : `extends:${layer.ref}`;
387
527
  }
388
528
  }
@@ -487,15 +627,41 @@ function pruneUnchangedInheritedFields(before, after, localRaw) {
487
627
  }
488
628
  /**
489
629
  * What to persist for a `mutateConfig`/`mutateConfigWithPrecommit` write:
490
- * the full effective `next` when the local file has no `extends` (unchanged
491
- * pre-#945 behavior), otherwise only the changed-or-already-local fields
492
- * (#945 finding above).
630
+ * only the changed-or-already-local fields. This applies both to inherited
631
+ * configs and ordinary configs: lifecycle mutations must not serialize every
632
+ * schema default merely because validation materialized it in memory (#972).
493
633
  */
494
- function configWriteBody(localRaw, current, next) {
495
- const usesExtends = typeof localRaw?.extends === "string" && localRaw.extends.trim().length > 0;
496
- if (!usesExtends)
634
+ function configWriteBody(localRaw, current, next, persistTopLevelKeys = []) {
635
+ // A first write keeps the established full-default scaffold. Besides being
636
+ // useful to a new user, the add -> rejected-install -> remove lifecycle
637
+ // relies on that symmetry to return a pristine install to DEFAULT_CONFIG.
638
+ if (localRaw === undefined)
497
639
  return next;
498
- return pruneUnchangedInheritedFields(current, next, localRaw);
640
+ const pruned = pruneUnchangedInheritedFields(current, next, localRaw);
641
+ // A caller may need to persist an explicit user choice even when it equals
642
+ // the schema default. Interactive setup uses this for semanticSearchMode:
643
+ // "off" means the user declined the opt-in, not an incidental default that
644
+ // should disappear from the saved configuration.
645
+ for (const key of persistTopLevelKeys) {
646
+ if (Object.hasOwn(next, key))
647
+ pruned[key] = next[key];
648
+ }
649
+ pruned.configVersion = CURRENT_CONFIG_VERSION;
650
+ // Keep existing top-level keys in their authored order. Lifecycle rollback
651
+ // writes the same logical object twice (add, then remove); reordering those
652
+ // surviving keys would violate its byte-parity guarantee even though the
653
+ // parsed JSON is equivalent. Newly changed keys follow in `next` order,
654
+ // while nested maps (notably `bundles`) retain mutation-selected ordering.
655
+ const ordered = {};
656
+ for (const key of Object.keys(localRaw)) {
657
+ if (Object.hasOwn(pruned, key))
658
+ ordered[key] = pruned[key];
659
+ }
660
+ for (const [key, value] of Object.entries(pruned)) {
661
+ if (!Object.hasOwn(ordered, key))
662
+ ordered[key] = value;
663
+ }
664
+ return ordered;
499
665
  }
500
666
  /**
501
667
  * Mutate config under one fail-closed lock spanning read, merge, validation,
@@ -527,7 +693,7 @@ export function mutateConfig(mutate, options) {
527
693
  * side effect. Setup uses this to reject a three-way conflict before creating
528
694
  * its stash, while preventing another config writer from racing the final save.
529
695
  */
530
- export async function mutateConfigWithPrecommit(mutate, precommit) {
696
+ export async function mutateConfigWithPrecommit(mutate, precommit, options) {
531
697
  cachedConfig = undefined;
532
698
  const configPath = getConfigPath();
533
699
  const release = acquireConfigLock();
@@ -543,7 +709,7 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
543
709
  if (mutated === current)
544
710
  return { config: current, written: false, precommit: precommitResult };
545
711
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
546
- writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
712
+ writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next, options?.persistTopLevelKeys)));
547
713
  return { config: next, written: true, precommit: precommitResult };
548
714
  }
549
715
  finally {
@@ -682,7 +848,7 @@ export function getIndexPassConfig(config, passName) {
682
848
  return entry;
683
849
  }
684
850
  // Re-export source runtime helpers — implementation lives in config-sources.ts.
685
- export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlesToSourceEntries, installedSourceDescriptor, parseSourceSpec, primaryBundlePath, resolveConfiguredSources, } from "./config-sources.js";
851
+ export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlePhysicalContentRoot, bundleSourceId, bundlesToSourceEntries, installedSourceDescriptor, isBundleEnabled, parseSourceSpec, primaryBundlePath, resolveActiveConfiguredSources, resolveConfiguredSources, } from "./config-sources.js";
686
852
  /**
687
853
  * Merge a partial user-config override onto a base config. Used by
688
854
  * {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { isBundleSlug } from "../asset/asset-ref.js";
2
3
  import { warnOnce } from "../warn.js";
3
4
  function isPlainRecord(value) {
@@ -49,6 +50,11 @@ function bundleFromLegacySource(entry, index) {
49
50
  const key = name && isBundleSlug(name) ? name : `source-${index + 1}`;
50
51
  return [key, bundle];
51
52
  }
53
+ function filesystemLocator(bundle) {
54
+ if (!isPlainRecord(bundle) || typeof bundle.path !== "string" || bundle.path.length === 0)
55
+ return undefined;
56
+ return path.resolve(bundle.path);
57
+ }
52
58
  export function migrateLegacySourceShape(raw, sourcePath) {
53
59
  const hasStashDir = typeof raw.stashDir === "string" && raw.stashDir.trim().length > 0;
54
60
  const hasSources = Array.isArray(raw.sources) && raw.sources.length > 0;
@@ -68,6 +74,9 @@ export function migrateLegacySourceShape(raw, sourcePath) {
68
74
  if (!converted)
69
75
  return;
70
76
  const [key, bundle] = converted;
77
+ const locator = filesystemLocator(bundle);
78
+ if (locator && Object.values(bundles).some((candidate) => filesystemLocator(candidate) === locator))
79
+ return;
71
80
  bundles[key] = bundle;
72
81
  defaultBundle ??= key;
73
82
  });
@@ -0,0 +1,23 @@
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
+ import { z } from "zod";
5
+ const toolName = z.string().trim().min(1, "expected a non-empty tool name");
6
+ /** Host-owned ceiling for capabilities requested by executable assets. */
7
+ export const ExecutionPolicyConfigSchema = z
8
+ .object({
9
+ /** Exact tool names an asset may request. `*` is an explicit allow-all ceiling. */
10
+ allowedTools: z
11
+ .array(toolName)
12
+ .default([])
13
+ .superRefine((tools, ctx) => {
14
+ const seen = new Set();
15
+ for (const [index, tool] of tools.entries()) {
16
+ if (seen.has(tool)) {
17
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [index], message: "duplicates an earlier tool name" });
18
+ }
19
+ seen.add(tool);
20
+ }
21
+ }),
22
+ })
23
+ .strict();
@@ -27,4 +27,4 @@ export const ExperimentalConfigSchema = z
27
27
  */
28
28
  improveAutonomy: z.boolean().optional(),
29
29
  })
30
- .passthrough();
30
+ .strict();
@@ -0,0 +1,20 @@
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
+ import { z } from "zod";
5
+ import { nonEmptyString } from "./primitives.js";
6
+ /** One host-local grant allowing an authored asset to create scheduler bindings. */
7
+ export const SchedulerActivationSchema = z
8
+ .object({
9
+ kind: z.enum(["task", "workflow"]),
10
+ ref: nonEmptyString,
11
+ /** Identity of the configured source this host approved, not merely its mutable bundle name. */
12
+ sourceId: z.string().regex(/^sha256:[0-9a-f]{64}$/),
13
+ })
14
+ .strict();
15
+ /** Absence from this allow-list means disabled. */
16
+ export const SchedulerConfigSchema = z
17
+ .object({
18
+ enabled: z.array(SchedulerActivationSchema).default([]),
19
+ })
20
+ .strict();
@@ -51,7 +51,7 @@ export const CurateRerankConfigSchema = z
51
51
  /** How many of curate's already-ranked candidates to send to the reranker. Default 8. */
52
52
  topN: positiveInt.max(50).optional(),
53
53
  })
54
- .passthrough();
54
+ .strict();
55
55
  export const SearchConfigSchema = z
56
56
  .object({
57
57
  minScore: nonNegativeNumber.optional(),
@@ -13,7 +13,7 @@ import { z } from "zod";
13
13
  // and, transitively, the indexer modules they delegate to).
14
14
  import { VALID_ADAPTER_IDS } from "../../adapter/adapter-ids.js";
15
15
  import { isBundleSlug } from "../../asset/asset-ref.js";
16
- import { httpUrl, nonEmptyString, positiveInt } from "./primitives.js";
16
+ import { httpUrl, isApiKeyReference, nonEmptyString, positiveInt } from "./primitives.js";
17
17
  const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
18
18
  // ── Sources / registries / installed ────────────────────────────────────────
19
19
  const SourceConfigEntryOptionsSchema = z.record(z.unknown());
@@ -25,6 +25,7 @@ export const SourceConfigEntrySchema = z
25
25
  name: z.string().min(1).optional(),
26
26
  enabled: z.boolean().optional(),
27
27
  writable: z.boolean().optional(),
28
+ credential: z.string().min(1).optional(),
28
29
  primary: z.boolean().optional(),
29
30
  options: SourceConfigEntryOptionsSchema.optional(),
30
31
  })
@@ -52,6 +53,20 @@ export const SourceConfigEntrySchema = z
52
53
  ").",
53
54
  });
54
55
  }
56
+ if (entry.credential !== undefined && entry.type !== "git") {
57
+ ctx.addIssue({
58
+ code: z.ZodIssueCode.custom,
59
+ path: ["credential"],
60
+ message: "credential is only supported on git sources",
61
+ });
62
+ }
63
+ if (entry.credential !== undefined && !isApiKeyReference(entry.credential)) {
64
+ ctx.addIssue({
65
+ code: z.ZodIssueCode.custom,
66
+ path: ["credential"],
67
+ message: "git credential must be a $VAR or secret://<name> reference",
68
+ });
69
+ }
55
70
  });
56
71
  export const RegistryConfigEntrySchema = z
57
72
  .object({
@@ -110,6 +125,8 @@ export const BundleConfigEntrySchema = z
110
125
  git: z.string().min(1).optional(),
111
126
  website: BundleWebsiteDescriptorSchema.optional(),
112
127
  npm: z.string().min(1).optional(),
128
+ /** Symbolic HTTPS bearer credential; resolved only at the Git subprocess boundary. */
129
+ credential: z.string().min(1).optional(),
113
130
  writable: z.boolean().optional(),
114
131
  // Opt a bundle out of indexing, search, refresh, and write targeting
115
132
  // without deleting it. The runtime honors the derived value in write and
@@ -150,6 +167,20 @@ export const BundleConfigEntrySchema = z
150
167
  message: "writable: true is only supported on path and git bundle sources",
151
168
  });
152
169
  }
170
+ if (entry.credential !== undefined && entry.git === undefined) {
171
+ ctx.addIssue({
172
+ code: z.ZodIssueCode.custom,
173
+ path: ["credential"],
174
+ message: "credential is only supported on git bundle sources",
175
+ });
176
+ }
177
+ if (entry.credential !== undefined && !isApiKeyReference(entry.credential)) {
178
+ ctx.addIssue({
179
+ code: z.ZodIssueCode.custom,
180
+ path: ["credential"],
181
+ message: "git credential must be a $VAR or secret://<name> reference",
182
+ });
183
+ }
153
184
  const componentEntries = entry.components ? Object.entries(entry.components) : [];
154
185
  if (entry.components !== undefined && componentEntries.length !== 1) {
155
186
  ctx.addIssue({
@@ -0,0 +1,52 @@
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
+ /** Marker emitted by output redaction. It must never become durable asset content. */
5
+ export const REDACTED_CONTENT_MARKER = "[REDACTED]";
6
+ /** Reflect prompt section that contains run diagnostics, not proposed asset content. */
7
+ export const REFLECT_AVOID_PATTERNS_HEADING = "Avoid These Patterns";
8
+ const REFLECT_AVOID_PATTERNS_RE = /^##[ \t]+Avoid These Patterns[ \t]*$/i;
9
+ const SECTION_BOUNDARY_RE = /^#{1,2}(?:[ \t]+|$)/;
10
+ export function containsRedactedContent(content) {
11
+ return content.includes(REDACTED_CONTENT_MARKER);
12
+ }
13
+ export function containsReflectPromptScaffolding(content) {
14
+ return content.split(/\r?\n/).some((line) => REFLECT_AVOID_PATTERNS_RE.test(line));
15
+ }
16
+ /**
17
+ * Remove every echoed run-only "Avoid These Patterns" section while preserving
18
+ * the next peer/top-level section. Reflect alone calls this sanitizer; authored
19
+ * source assets are never rewritten by this helper.
20
+ */
21
+ export function stripReflectPromptScaffolding(content) {
22
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
23
+ const lines = content.split(/\r?\n/);
24
+ const kept = [];
25
+ let stripped = false;
26
+ for (let index = 0; index < lines.length;) {
27
+ if (!REFLECT_AVOID_PATTERNS_RE.test(lines[index] ?? "")) {
28
+ kept.push(lines[index] ?? "");
29
+ index += 1;
30
+ continue;
31
+ }
32
+ stripped = true;
33
+ index += 1;
34
+ while (index < lines.length && !SECTION_BOUNDARY_RE.test(lines[index] ?? ""))
35
+ index += 1;
36
+ }
37
+ return { content: kept.join(newline).replace(/(?:\r?\n){3,}/g, `${newline}${newline}`), stripped };
38
+ }
39
+ /**
40
+ * Return a secret-free rejection reason when generated content is unsafe to
41
+ * persist. `redactedContent` is the same body after applying the dispatch
42
+ * lease's sensitive-value inventory; comparing it avoids exposing the value.
43
+ */
44
+ export function generatedContentRejection(content, redactedContent) {
45
+ if (containsRedactedContent(content)) {
46
+ return `Agent proposal content contains ${REDACTED_CONTENT_MARKER}; refusing to persist already-redacted text.`;
47
+ }
48
+ if (redactedContent !== content) {
49
+ return "Agent proposal content echoed a configured credential; refusing to persist either the secret or a redacted replacement.";
50
+ }
51
+ return undefined;
52
+ }
@@ -31,14 +31,14 @@ const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
31
31
  * very same instant (e.g. two `akm index` runs a scheduler launched back to
32
32
  * back) can still collide on it; retrying briefly resolves that ordinary
33
33
  * case instead of failing a legitimate concurrent invocation outright
34
- * (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
35
- * still surfaces the busy error promptly rather than making a losing
36
- * process hang — comfortably above the barrier's normal hold time, well
37
- * below a length that would make this feel like the blocking lock #872
38
- * removed. Never applies to the rebuild lock itself, which stays
34
+ * (field follow-up to #956, G1). Five seconds matches the async and
35
+ * synchronous-activity registration paths below and tolerates a holder being
36
+ * descheduled under heavy process-shard load; the barrier's normal hold time
37
+ * remains sub-millisecond. The bound still surfaces a genuinely wedged holder
38
+ * promptly and never applies to the rebuild lock itself, which stays
39
39
  * non-blocking (#872).
40
40
  */
41
- const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
41
+ const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 5_000;
42
42
  let busyRetryBoundMsForTests;
43
43
  /** Test-only override for {@link MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS}, so a unit test can exercise the
44
44
  * exhausted-retry throw without a real ~1.5s wait. Restored via tests/_helpers/seams.ts's resetAllSeams(). */
@@ -105,7 +105,7 @@ export const TYPE_PRESENTATION = {
105
105
  task: {
106
106
  label: "Task",
107
107
  renderer: "task-yaml",
108
- action: (ref) => `akm show ${ref} -> inspect; akm task run <id> -> run now; edit the file + akm task sync -> unschedule`,
108
+ action: (ref) => `akm show ${ref} -> inspect; akm task run <id> -> run now; akm task enable|disable ${ref} -> change local scheduling`,
109
109
  fragmentRef: false,
110
110
  },
111
111
  session: {
@@ -34,7 +34,7 @@ import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
34
34
  import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
35
35
  import { deriveBundleId } from "./bundle-id.js";
36
36
  import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
37
- import { bundleContentRoot, resolveConfiguredSources } from "./config/config.js";
37
+ import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources } from "./config/config.js";
38
38
  import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
40
40
  import { warn, warnOnce } from "./warn.js";
@@ -897,12 +897,16 @@ function gitTargetNotMaterialized(target, contentRoot) {
897
897
  * see plan §6 decision 3 for the rationale.
898
898
  */
899
899
  export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
900
- const configuredSources = resolveConfiguredSources(akmConfig);
900
+ const allConfiguredSources = resolveConfiguredSources(akmConfig);
901
+ const configuredSources = resolveActiveConfiguredSources(akmConfig);
901
902
  const requireWritable = options.requireWritable !== false;
902
903
  // 1. Explicit --target wins.
903
904
  if (explicitTarget) {
904
905
  const match = configuredSources.find((s) => s.name === explicitTarget);
905
906
  if (!match) {
907
+ if (allConfiguredSources.some((source) => source.name === explicitTarget)) {
908
+ throw new UsageError(`Bundle "${explicitTarget}" is disabled.`, "INVALID_FLAG_VALUE");
909
+ }
906
910
  throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
907
911
  }
908
912
  // Up-front writable check so an explicit --target fails fast with a
@@ -939,15 +943,13 @@ export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
939
943
  }
940
944
  /** Resolve the implicit working stash without consulting `defaultWriteTarget`. */
941
945
  export function resolveWorkingStashTarget(akmConfig, options = {}) {
942
- const configuredSources = resolveConfiguredSources(akmConfig);
946
+ const allConfiguredSources = resolveConfiguredSources(akmConfig);
947
+ const configuredSources = resolveActiveConfiguredSources(akmConfig);
943
948
  const requireWritable = options.requireWritable !== false;
944
949
  if (process.env.AKM_BUNDLE_DIR?.trim()) {
945
950
  const stashDir = resolveStashDir();
946
- const resolvedStashDir = path.resolve(stashDir);
947
- const configured = configuredSources.find((source) => {
948
- const sourcePath = source.source.type === "filesystem" ? source.source.path : undefined;
949
- return sourcePath !== undefined && bundleContentRoot(sourcePath, source.componentRoot) === resolvedStashDir;
950
- });
951
+ const configuredBundleId = bundleKeyForContentRoot(akmConfig, stashDir);
952
+ const configured = configuredSources.find((source) => source.name === configuredBundleId);
951
953
  if (configured) {
952
954
  const target = adaptConfiguredSource(configured);
953
955
  if (requireWritable && !resolveWritable(target.config)) {
@@ -955,6 +957,9 @@ export function resolveWorkingStashTarget(akmConfig, options = {}) {
955
957
  }
956
958
  return { ...target, selector: undefined };
957
959
  }
960
+ if (allConfiguredSources.some((source) => source.name === configuredBundleId)) {
961
+ throw new ConfigError("The AKM_BUNDLE_DIR source is disabled in config.", "INVALID_CONFIG_FILE");
962
+ }
958
963
  const bundleId = deriveBundleId(undefined, stashDir, new Set(Object.keys(akmConfig.bundles ?? {})));
959
964
  return {
960
965
  source: { kind: "filesystem", name: bundleId, path: stashDir, adapterId: detectAdapterId(stashDir) },