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
@@ -18,7 +18,7 @@ import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
18
18
  import { conceptIdFromTypeName, isFullRefInput } from "../../core/asset/resolve-ref.js";
19
19
  import { isWithin, resolveStashDir } from "../../core/common.js";
20
20
  import { loadConfig } from "../../core/config/config.js";
21
- import { resolveConfiguredSources } from "../../core/config/config-sources.js";
21
+ import { bundleComponentConfig, bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
22
22
  import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
23
23
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
24
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
@@ -26,6 +26,7 @@ import { warn } from "../../core/warn.js";
26
26
  import { commitWriteTargetBoundary, deleteAssetFromSource, 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
+ import { activeSchedulerActivations, isSchedulerRefEnabled, setSchedulerRefEnabled, } from "../../tasks/activation-config.js";
29
30
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
30
31
  import { prepareTaskV3Execution } from "../../tasks/prepare/prepare.js";
31
32
  import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
@@ -36,7 +37,7 @@ import { exitCodeForStatus } from "../../tasks/run/task-result.js";
36
37
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
37
38
  import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
38
39
  import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
39
- import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
40
+ import { assertSchedulerBackendInspection, assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
40
41
  import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
41
42
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
42
43
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
@@ -81,7 +82,6 @@ export async function akmTasksAdd(input, deps = {}) {
81
82
  description: input.description,
82
83
  when_to_use: input.when_to_use,
83
84
  tags: input.tags,
84
- enabled: input.disabled !== true,
85
85
  });
86
86
  const parsedTask = parseTaskSource({ yaml, filePath: assetPath, workspaceRoot: stashDir });
87
87
  const task = projectTaskSourceV4(parsedTask.v4);
@@ -94,24 +94,15 @@ export async function akmTasksAdd(input, deps = {}) {
94
94
  config: bundle.config,
95
95
  resolveAsset: taskProjectionAssetResolver(bundle.config, bundle.bundleName, stashDir),
96
96
  });
97
- // Bindings are compiled from the ORIGINAL parsed document, not the
98
- // `task`/`projectTaskSourceV4` projection above — mirroring
99
- // scheduler-sync.ts's compileTaskSources (spec §3.2.7's project-v4.ts
100
- // header): the projection deliberately drops each schedule entry's own
101
- // `enabled` (P4-N6), so building bindings from it would silently ignore
102
- // `--disabled`. A task source v4 document has no document-level
103
- // `akm.enabled`, so `enabled: true` is passed at the document level and
104
- // every entry's own `enabled` (always present, defaulted at parse time)
105
- // decides.
97
+ // Bindings are compiled from the original parsed document so each
98
+ // schedule entry's inputs reach the generated invocation.
106
99
  const taskBindings = compileTaskSchedulerBindings({
107
100
  id,
108
101
  qualifiedRef,
109
102
  ...(bundle.installTarget ? { bundleTarget: bundle.installTarget } : {}),
110
- enabled: true,
111
103
  schedules: parsedTask.v4.schedule.map((schedule) => ({
112
104
  cron: schedule.cron,
113
105
  ordinal: schedule.ordinal,
114
- enabled: schedule.enabled,
115
106
  source: schedule.source,
116
107
  inputs: schedule.inputs,
117
108
  })),
@@ -124,6 +115,25 @@ export async function akmTasksAdd(input, deps = {}) {
124
115
  const writeAsset = deps.writeAsset ?? writeAssetToSource;
125
116
  const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
126
117
  const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
118
+ if (input.disabled) {
119
+ // Revoke before publishing replacement bytes. If a write/commit/sync
120
+ // fails, the safe partial state is an inert task, never an activated new
121
+ // source (or a stale native binding that can still dispatch it).
122
+ setSchedulerRefEnabled("task", qualifiedRef, false);
123
+ await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
124
+ commitBoundary(writeTarget, `Update tasks/${id}`);
125
+ await akmTasksSync(deps, bundle.bundleName);
126
+ return {
127
+ id,
128
+ ref: conceptIdFromTypeName("task", id),
129
+ path: assetPath,
130
+ bundleDir: stashDir,
131
+ schedule: taskBinding.cron,
132
+ enabled: false,
133
+ backend,
134
+ target: task.target,
135
+ };
136
+ }
127
137
  const transaction = await prepareTaskAddSchedulerTransaction({
128
138
  id,
129
139
  installTarget: bundle.installTarget,
@@ -213,13 +223,17 @@ export async function akmTasksAdd(input, deps = {}) {
213
223
  suppressNativeRollbackWhenExternalFails: true,
214
224
  allowNativeRollbackAfterExternalFailure: (error) => error instanceof TaskSourceRestoredBoundaryError,
215
225
  });
226
+ // Publish the host-local grant only after source and native state commit.
227
+ // A failure before this point therefore leaves any new binding inert at
228
+ // its scheduled-fire gate instead of authorizing partially committed code.
229
+ setSchedulerRefEnabled("task", qualifiedRef, true);
216
230
  return {
217
231
  id,
218
232
  ref: conceptIdFromTypeName("task", id),
219
233
  path: assetPath,
220
234
  bundleDir: stashDir,
221
235
  schedule: taskBinding.cron,
222
- enabled: taskBinding.enabled,
236
+ enabled: true,
223
237
  backend,
224
238
  target: task.target,
225
239
  };
@@ -250,6 +264,11 @@ export async function akmTasksRun(id, options = {}) {
250
264
  const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
251
265
  const resolvedId = taskIdForAdapter(parsed.id, adapterId);
252
266
  const scheduled = options.scheduled === true;
267
+ const conceptId = adapterId === "akm" ? `tasks/${resolvedId}` : resolvedId;
268
+ const qualifiedRef = makeBundleRef(bundle.source.name, conceptId);
269
+ if (scheduled && !isSchedulerRefEnabled(loadConfig(), "task", qualifiedRef)) {
270
+ throw new UsageError(`Scheduled task ${JSON.stringify(qualifiedRef)} is not enabled in local scheduler config; run \`akm task enable ${qualifiedRef}\`.`, "INVALID_FLAG_VALUE");
271
+ }
253
272
  // D5 "Construction" (spec docs/plans/specs/p1b-model-extraction.md §1.2/
254
273
  // §5.2): built ONCE at this invocation boundary. eventSource is "task"
255
274
  // whether or not --scheduled was passed (§1.6 D5-N1) — scheduled stays a
@@ -299,6 +318,42 @@ export async function akmTasksRun(id, options = {}) {
299
318
  exitCode,
300
319
  };
301
320
  }
321
+ function resolveTaskActivation(ref, target, requireSchedulable) {
322
+ const parsed = parseTaskRef(ref);
323
+ const bundle = resolveTaskReadBundle(parsed.bundle, target);
324
+ const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
325
+ const id = taskIdForAdapter(parsed.id, adapterId);
326
+ const conceptId = adapterId === "akm" ? `tasks/${id}` : id;
327
+ const sourcePath = adapterId === "akm"
328
+ ? path.join(bundle.source.path, "tasks", `${id}.yml`)
329
+ : path.join(bundle.source.path, `${id}.yml`);
330
+ if (requireSchedulable && !fs.existsSync(sourcePath)) {
331
+ throw new NotFoundError(`Task ${JSON.stringify(makeBundleRef(bundle.source.name, conceptId))} was not found.`);
332
+ }
333
+ if (requireSchedulable) {
334
+ const parsedSource = parseTaskSource({
335
+ yaml: fs.readFileSync(sourcePath, "utf8"),
336
+ filePath: sourcePath,
337
+ workspaceRoot: bundle.source.path,
338
+ });
339
+ if (parsedSource.v4.schedule.length === 0) {
340
+ throw new UsageError(`Task ${JSON.stringify(ref)} has no schedule to enable.`, "INVALID_FLAG_VALUE");
341
+ }
342
+ }
343
+ return { qualifiedRef: makeBundleRef(bundle.source.name, conceptId), sourcePath, bundleName: bundle.source.name };
344
+ }
345
+ export async function akmTasksEnable(ref, options = {}, deps = {}) {
346
+ const resolved = resolveTaskActivation(ref, options.target, true);
347
+ const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, true);
348
+ const sync = await akmTasksSync(deps, resolved.bundleName);
349
+ return { ref: resolved.qualifiedRef, enabled: true, changed: activation.changed, sync };
350
+ }
351
+ export async function akmTasksDisable(ref, options = {}, deps = {}) {
352
+ const resolved = resolveTaskActivation(ref, options.target, false);
353
+ const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, false);
354
+ const sync = await akmTasksSync(deps, resolved.bundleName);
355
+ return { ref: resolved.qualifiedRef, enabled: false, changed: activation.changed, sync };
356
+ }
302
357
  export async function akmTasksHistory(input) {
303
358
  const limit = input.limit !== undefined && input.limit > 0 ? input.limit : 50;
304
359
  const parsed = input.id ? parseTaskRef(input.id) : undefined;
@@ -309,23 +364,15 @@ export async function akmTasksHistory(input) {
309
364
  return { rows: readTaskHistory({ id, limit }) };
310
365
  }
311
366
  /**
312
- * Reconcile the on-disk task files of ONE bundle with the OS scheduler.
313
- * • compile and runtime-project the complete desired task/workflow set;
314
- * one invalid source rejects the sync before descriptor/backend mutation
367
+ * Reconcile host-local scheduler activation with authored tasks/workflows.
368
+ * • without --bundle, scan every enabled configured bundle in one transaction
369
+ * • with --bundle, reconcile only that bundle
370
+ * • only refs present in scheduler.enabled enter the desired set
315
371
  * • install missing bindings only after that whole-set preflight succeeds
316
- * • reinstall tasks whose schedule or enabled state changed in the .yml
372
+ * • reinstall bindings whose authored schedule or runtime state changed
317
373
  * (drift detected by comparing the backend's installed signature against
318
374
  * the signature the current definition would produce)
319
375
  * • remove orphan scheduler entries that no longer have a backing file
320
- *
321
- * `--bundle <bundle>` scopes the reconciliation to that bundle: the file set is
322
- * the bundle's `tasks/*.yml` and — crucially — the scheduler entries considered
323
- * are ONLY those attributed to the same bundle (parsed from the installed
324
- * `--bundle` token; absent ⇒ primary). This is the security boundary that keeps
325
- * "registering a bundle never activates code": a plain (primary) sync never
326
- * installs from, updates, or removes another bundle's entries, and sync never
327
- * scans all bundles. Activation happens only through explicit `add --bundle`
328
- * (or `sync --bundle` on a bundle whose task files are already present).
329
376
  */
330
377
  /**
331
378
  * Compute (but never apply) a scheduler sync plan: everything through
@@ -337,10 +384,7 @@ export async function akmTasksHistory(input) {
337
384
  * invoked here — only `applySchedulerSyncPlan` may call it.
338
385
  */
339
386
  async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
340
- const resolved = resolveTaskReadBundle(undefined, bundleTarget);
341
387
  const config = loadConfig();
342
- const stashDir = resolved.source.path;
343
- const syncTarget = bundleTarget !== undefined && !isPrimaryStashPath(stashDir) ? bundleTarget : undefined;
344
388
  const sched = deps.backend ?? selectBackend();
345
389
  if (!sched.inspectBindings) {
346
390
  throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for transactional sync.`, "INVALID_CONFIG_FILE");
@@ -365,51 +409,176 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
365
409
  };
366
410
  });
367
411
  const nativeArtifacts = inspection.artifacts;
368
- const common = {
369
- sourceRoot: stashDir,
370
- adapterId: resolved.source.adapterId ?? detectAdapterId(stashDir),
371
- bundleName: resolved.source.name,
372
- // #846: only meaningful for a primary/unconfigured-bundle sync. A
373
- // `--bundle <target>` entry's scheduler-context descriptor records the
374
- // invoking process's OWN primary AKM_BUNDLE_DIR, not the targeted
375
- // bundle's directory, so path-scoping stays gated on the case it's
376
- // actually valid for (see belongsToBundle).
377
- ...(syncTarget === undefined ? { bundlePath: path.resolve(stashDir) } : {}),
378
- ...(syncTarget ? { bundleTarget: syncTarget } : {}),
379
- backend: sched.name,
380
- installed: allEntries,
381
- nativeArtifacts,
382
- inspection: Object.freeze({ installed: allEntries, artifacts: nativeArtifacts }),
383
- rebind: options.rebind === true,
384
- config,
385
- resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
386
- };
387
- // Pass one validates every desired source, ownership domain, schedule, and
388
- // installed-id collision before runtime descriptor preparation is possible.
389
- const preparedSources = await prepareSchedulerSyncSourceSet(common);
390
- const preflight = finalizeSchedulerSyncPlan(common, preparedSources);
412
+ const configuredSources = resolveConfiguredSources(config);
413
+ const activeSources = resolveActiveConfiguredSources(config);
414
+ const sourceNames = bundleTarget ? [bundleTarget] : activeSources.map((source) => source.name);
415
+ const inactiveOperations = bundleTarget
416
+ ? []
417
+ : inactiveBundleRemovalOperations(config, configuredSources, allEntries, nativeArtifacts);
418
+ if (!bundleTarget && sourceNames.length === 0 && configuredSources.length > 0) {
419
+ assertSchedulerBackendInspection({ installed: allEntries, artifacts: nativeArtifacts });
420
+ const plan = emptySchedulerSyncPlan(inactiveOperations);
421
+ return { sched, plan, sourceSnapshots: Object.freeze([]), prepared: undefined, warnings: [] };
422
+ }
423
+ const selectedNames = sourceNames.length > 0 ? sourceNames : [undefined];
424
+ const enabled = activeSchedulerActivations(config);
425
+ const enabledActivations = new Set(enabled.map((activation) => `${activation.kind}\0${activation.ref}`));
426
+ const preparedSets = [];
427
+ for (const sourceName of selectedNames) {
428
+ const resolved = resolveTaskReadBundle(undefined, sourceName);
429
+ const stashDir = resolved.source.path;
430
+ const syncTarget = sourceName !== undefined && !isPrimaryStashPath(stashDir) ? sourceName : undefined;
431
+ const common = {
432
+ sourceRoot: stashDir,
433
+ adapterId: resolved.source.adapterId ?? detectAdapterId(stashDir),
434
+ bundleName: resolved.source.name,
435
+ ...(syncTarget === undefined ? { bundlePath: path.resolve(stashDir) } : {}),
436
+ ...(syncTarget ? { bundleTarget: syncTarget } : {}),
437
+ backend: sched.name,
438
+ installed: allEntries,
439
+ nativeArtifacts,
440
+ inspection: Object.freeze({ installed: allEntries, artifacts: nativeArtifacts }),
441
+ enabledActivations,
442
+ rebind: options.rebind === true,
443
+ config,
444
+ resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
445
+ };
446
+ preparedSets.push({
447
+ common,
448
+ preparedSources: await prepareSchedulerSyncSourceSet(common),
449
+ ...(syncTarget ? { syncTarget } : {}),
450
+ });
451
+ }
452
+ // Pass one validates every selected bundle and computes whether any desired
453
+ // activation needs a new runtime descriptor before native mutation begins.
454
+ const preflights = preparedSets.map(({ common, preparedSources }) => finalizeSchedulerSyncPlan(common, preparedSources));
391
455
  const warnings = [];
392
456
  const expectedSignature = sched.expectedSignature?.bind(sched);
393
- const needsRuntime = preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined);
457
+ const needsRuntime = preflights.some((preflight) => preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined));
394
458
  const prepared = needsRuntime
395
- ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, warnings, allEntries.map((entry) => entry.binding))
459
+ ? prepareSchedulerSyncRuntime(undefined, deps, warnings, allEntries.map((entry) => entry.binding))
396
460
  : undefined;
397
- const plan = finalizeSchedulerSyncPlan({
461
+ const plans = preparedSets.map(({ common, preparedSources, syncTarget }) => finalizeSchedulerSyncPlan({
398
462
  ...common,
399
- ...(prepared?.options ? { installOptions: prepared.options } : {}),
463
+ ...(prepared?.options
464
+ ? { installOptions: { ...prepared.options, ...(syncTarget ? { target: syncTarget } : {}) } }
465
+ : syncTarget
466
+ ? { installOptions: { target: syncTarget } }
467
+ : {}),
400
468
  ...(expectedSignature
401
469
  ? {
402
470
  expectedSignature: (binding, install) => expectedSignature(binding, install),
403
471
  }
404
472
  : {}),
405
- }, preparedSources);
406
- return { sched, plan, prepared, warnings };
473
+ }, preparedSources));
474
+ assertNoCrossBundleSchedulerCollisions(plans);
475
+ const representedRefs = new Set(plans.flatMap((candidate) => [
476
+ ...candidate.desired.map((binding) => binding.logicalSource.ref),
477
+ ...candidate.failures.flatMap((failure) => (failure.ref ? [failure.ref] : [])),
478
+ ]));
479
+ const selectedBundleNames = new Set(preparedSets.map(({ common }) => common.bundleName));
480
+ const missingActivationFailures = enabled
481
+ .filter((activation) => {
482
+ const bundle = parseBundleRef(activation.ref).bundle;
483
+ return bundle !== undefined && selectedBundleNames.has(bundle) && !representedRefs.has(activation.ref);
484
+ })
485
+ .map((activation) => ({
486
+ path: activation.ref,
487
+ ref: activation.ref,
488
+ reason: `Enabled ${activation.kind} ${JSON.stringify(activation.ref)} was not found or has no schedule.`,
489
+ }));
490
+ const first = plans[0];
491
+ if (!first)
492
+ throw new ConfigError("No configured bundle is available for scheduler sync.", "INVALID_CONFIG_FILE");
493
+ const plan = Object.freeze({
494
+ desired: Object.freeze(plans.flatMap((candidate) => candidate.desired)),
495
+ installed: Object.freeze(plans.flatMap((candidate) => candidate.installed)),
496
+ updated: Object.freeze(plans.flatMap((candidate) => candidate.updated)),
497
+ removed: Object.freeze([
498
+ ...plans.flatMap((candidate) => candidate.removed),
499
+ ...inactiveOperations.map((operation) => operation.id),
500
+ ]),
501
+ unchanged: Object.freeze(plans.flatMap((candidate) => candidate.unchanged)),
502
+ operations: Object.freeze([...plans.flatMap((candidate) => candidate.operations), ...inactiveOperations]),
503
+ sourceSnapshot: first.sourceSnapshot,
504
+ failures: Object.freeze([...plans.flatMap((candidate) => candidate.failures), ...missingActivationFailures]),
505
+ });
506
+ return { sched, plan, sourceSnapshots: plans.map((candidate) => candidate.sourceSnapshot), prepared, warnings };
507
+ }
508
+ function inactiveBundleRemovalOperations(config, configuredSources, installed, artifacts) {
509
+ const inactive = new Set(configuredSources.filter((source) => source.enabled === false).map((source) => source.name));
510
+ if (inactive.size === 0)
511
+ return [];
512
+ return installed
513
+ .map((entry) => ({ entry, bundleName: installedSchedulerBundle(config, entry) }))
514
+ .filter((candidate) => candidate.bundleName !== undefined && inactive.has(candidate.bundleName))
515
+ .sort((left, right) => left.entry.id.localeCompare(right.entry.id))
516
+ .map(({ entry, bundleName }) => {
517
+ const adapterId = bundleComponentConfig(config.bundles?.[bundleName])?.adapter ?? "akm";
518
+ return buildSchedulerRemoveOperation(entry.id, entry, artifacts, { adapterId, bundleName });
519
+ });
520
+ }
521
+ function installedSchedulerBundle(config, entry) {
522
+ const direct = entry.target ?? (entry.invocation ? scheduledInvocationBundle(entry.invocation) : undefined);
523
+ if (direct !== undefined)
524
+ return direct;
525
+ if (entry.ownerBundlePath === undefined)
526
+ return undefined;
527
+ return bundleKeyForContentRoot(config, entry.ownerBundlePath);
528
+ }
529
+ function emptySchedulerSyncPlan(operations) {
530
+ const sourceSnapshot = Object.freeze({
531
+ adapterId: "akm",
532
+ sourceRoot: "",
533
+ sourceRealPath: "",
534
+ sourcePhysicalIdentity: "inactive",
535
+ sourceDirectoryVersion: "inactive",
536
+ files: Object.freeze([]),
537
+ directoryManifests: Object.freeze([]),
538
+ });
539
+ return Object.freeze({
540
+ desired: Object.freeze([]),
541
+ installed: Object.freeze([]),
542
+ updated: Object.freeze([]),
543
+ removed: Object.freeze(operations.map((operation) => operation.id)),
544
+ unchanged: Object.freeze([]),
545
+ operations: Object.freeze([...operations]),
546
+ sourceSnapshot,
547
+ failures: Object.freeze([]),
548
+ });
549
+ }
550
+ function scheduledInvocationBundle(invocation) {
551
+ const bundleIndex = invocation.indexOf("--bundle");
552
+ if (bundleIndex >= 0)
553
+ return invocation[bundleIndex + 1];
554
+ if (invocation[0] === "workflow" && invocation[1] === "run" && invocation[2]) {
555
+ try {
556
+ return parseBundleRef(invocation[2]).bundle;
557
+ }
558
+ catch {
559
+ return undefined;
560
+ }
561
+ }
562
+ return undefined;
563
+ }
564
+ function assertNoCrossBundleSchedulerCollisions(plans) {
565
+ const owners = new Map();
566
+ for (const binding of plans.flatMap((plan) => plan.desired)) {
567
+ const key = schedulerNativeArtifactKey(schedulerBindingNativeId(binding));
568
+ const existing = owners.get(key);
569
+ if (existing && existing.logicalSource.ref !== binding.logicalSource.ref) {
570
+ throw new UsageError(`Scheduler native id ${JSON.stringify(schedulerBindingNativeId(binding))} is claimed by both ` +
571
+ `${JSON.stringify(existing.logicalSource.ref)} and ${JSON.stringify(binding.logicalSource.ref)}. ` +
572
+ "Rename one task before enabling both.", "RESOURCE_ALREADY_EXISTS");
573
+ }
574
+ owners.set(key, binding);
575
+ }
407
576
  }
408
577
  export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
409
- const { sched, plan, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
578
+ const { sched, plan, sourceSnapshots, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
410
579
  await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
411
580
  ? prepared.publish
412
- : undefined);
581
+ : undefined, sourceSnapshots);
413
582
  return {
414
583
  installed: [...plan.installed],
415
584
  updated: [...plan.updated],
@@ -600,10 +769,13 @@ export async function akmTasksDoctor(deps = {}) {
600
769
  };
601
770
  }
602
771
  // ── helpers ─────────────────────────────────────────────────────────────────
603
- async function applySchedulerSyncPlan(backend, plan, publish) {
772
+ async function applySchedulerSyncPlan(backend, plan, publish, sourceSnapshots = [plan.sourceSnapshot]) {
604
773
  await applySchedulerTransaction(backend, plan.operations, {
605
774
  initialExpectations: plan.operations.map((operation) => operation.expected),
606
- assertReadSet: () => assertSchedulerSourceSnapshot(plan.sourceSnapshot),
775
+ assertReadSet: () => {
776
+ for (const snapshot of sourceSnapshots)
777
+ assertSchedulerSourceSnapshot(snapshot);
778
+ },
607
779
  beforeOperation: (_operation, index) => {
608
780
  if (index === 0)
609
781
  publish?.();
@@ -1114,7 +1286,7 @@ export function resolveTaskReadBundle(refBundle, flagBundle) {
1114
1286
  resolved = resolveWorkingStashTarget(config, { requireWritable: false });
1115
1287
  }
1116
1288
  else {
1117
- const configured = resolveConfiguredSources(config).some((source) => source.name === selector);
1289
+ const configured = resolveActiveConfiguredSources(config).some((source) => source.name === selector);
1118
1290
  const implicit = configured ? undefined : resolveImplicitScheduledBundleTarget(config, selector);
1119
1291
  resolved = implicit ?? resolveWriteTarget(config, selector, { requireWritable: false });
1120
1292
  }
@@ -1243,22 +1415,8 @@ function renderTaskYaml(input) {
1243
1415
  obj.model = input.model;
1244
1416
  if (input.timeoutMs !== undefined)
1245
1417
  obj.timeout = input.timeoutMs;
1246
- // Task source v4's `enabled` is per schedule-binding, not document-level
1247
- // (P4-N6, row B-21): `--disabled` writes a one-entry schedule[] list
1248
- // carrying `enabled: false` rather than the v3 `akm.enabled: false` flag.
1249
- // `TasksAddInput.schedule`/the `add` CLI's `--schedule` are both still
1250
- // required, so the "no schedule to disable" usage error B-21 also
1251
- // describes is unreachable through this call site today; the check below
1252
- // still guards `renderTaskYaml` itself against ever being called with an
1253
- // empty schedule string.
1254
- if (input.schedule.length === 0) {
1255
- if (!input.enabled) {
1256
- throw new UsageError("--disabled requires --schedule; a task with no schedule is already manual-only.");
1257
- }
1258
- }
1259
- else {
1260
- obj.schedule = input.enabled ? input.schedule : [{ cron: input.schedule, enabled: false }];
1261
- }
1418
+ if (input.schedule.length > 0)
1419
+ obj.schedule = input.schedule;
1262
1420
  return yamlStringify(obj);
1263
1421
  }
1264
1422
  function assertInlineTaskPrompt(input) {
@@ -1282,119 +1440,6 @@ function parseJsonObjectArg(raw) {
1282
1440
  }
1283
1441
  return parsed;
1284
1442
  }
1285
- /**
1286
- * Toggle a task source v4 YAML file's `enabled` state without a full
1287
- * parse/render round-trip (which would reformat the file). Task source v4
1288
- * has no document-level `enabled` flag — it lives on each `schedule[]` entry
1289
- * instead (D2-N5, P4-N6) — so this walks the top-level `schedule:` block,
1290
- * finds every list entry in it (each line starting with `-` at the block's
1291
- * item indent), and toggles that entry's own `enabled:` key, the closest v4
1292
- * equivalent of v3's single document-level flag broadcasting to every
1293
- * trigger. Each entry is handled independently — one entry already carrying
1294
- * `enabled:` and a sibling entry with no such key (D2-N3's `schedule[i]`
1295
- * shape: `{cron, enabled?, inputs?}`) toggles the first and inserts into the
1296
- * second, rather than one entry's existing key short-circuiting the other's
1297
- * insertion.
1298
- *
1299
- * A bare string-shorthand schedule (`schedule: "0 9 * * *"`) has nowhere for
1300
- * `enabled:` to live and is rewritten to the one-entry list form. A list
1301
- * entry with no explicit `enabled:` key (defaulting to `true` at parse) gets
1302
- * one inserted rather than being silently left unaffected. A document with
1303
- * no `schedule:` key at all throws — there is no trigger to enable or
1304
- * disable (mirrors `renderTaskYaml`'s `--disabled`-with-no-`--schedule`
1305
- * usage error, row B-21).
1306
- *
1307
- * Each entry's own key indent is taken from its `-` line (the indent before
1308
- * `-`, plus two spaces for the conventional single space after it), so a
1309
- * nested mapping inside an entry — e.g. `schedule[i].inputs` — sits deeper
1310
- * and is never mistaken for the entry's own `enabled:` key.
1311
- *
1312
- * Preserves inline comments (e.g. `enabled: true # important`) and uses
1313
- * case-sensitive matching (YAML keys are case-sensitive).
1314
- */
1315
- export function setEnabledInYaml(yaml, enabled) {
1316
- const lines = yaml.replace(/\r\n/g, "\n").split("\n");
1317
- const scalarLine = lines.findIndex((line) => /^schedule:[ \t]+\S/.test(line));
1318
- if (scalarLine >= 0) {
1319
- const line = lines[scalarLine];
1320
- const match = line?.match(/^schedule:[ \t]+([^\r\n]+?)[ \t]*(#[^\r\n]*)?$/);
1321
- const cron = match?.[1] ?? "";
1322
- const comment = match?.[2] ? ` ${match[2]}` : "";
1323
- lines.splice(scalarLine, 1, "schedule:", ` - cron: ${cron}${comment}`, ` enabled: ${enabled}`);
1324
- return `${lines.join("\n").trimEnd()}\n`;
1325
- }
1326
- const blockLine = lines.findIndex((line) => /^schedule:\s*(?:#.*)?$/.test(line));
1327
- if (blockLine < 0) {
1328
- throw new UsageError("Task source v4 must declare a schedule before its enabled state can be toggled.");
1329
- }
1330
- // Find the block's extent and every top-level list item (`-`) within it.
1331
- // Only items at the *first* item's own indent count as entries — anything
1332
- // deeper belongs to a nested mapping/list inside an entry (e.g. an array
1333
- // input under `inputs:`) and must not be treated as a sibling entry.
1334
- let blockEnd = lines.length;
1335
- const itemStarts = [];
1336
- let topIndent = null;
1337
- for (let index = blockLine + 1; index < lines.length; index += 1) {
1338
- const line = lines[index];
1339
- if (line === undefined) {
1340
- blockEnd = index;
1341
- break;
1342
- }
1343
- if (line !== "" && !/^[ \t]/.test(line)) {
1344
- blockEnd = index;
1345
- break;
1346
- }
1347
- const itemMatch = line.match(/^([ \t]*)-(?=[ \t]|$)/);
1348
- if (itemMatch) {
1349
- const itemIndent = itemMatch[1] ?? "";
1350
- if (topIndent === null)
1351
- topIndent = itemIndent;
1352
- if (itemIndent === topIndent)
1353
- itemStarts.push(index);
1354
- }
1355
- }
1356
- if (itemStarts.length === 0) {
1357
- throw new UsageError("Task source v4's schedule: block has no entries to toggle enabled on.");
1358
- }
1359
- // Walk entries back-to-front: inserting a missing `enabled:` line shifts
1360
- // every later line index by one, but never touches `itemStarts[j]` for
1361
- // j <= i (an insertion for entry i lands at `itemStarts[i] + 1`, which is
1362
- // at or after entry i's own start), so already-computed start/end bounds
1363
- // for entries processed later in this loop (earlier in the list) stay valid.
1364
- for (let i = itemStarts.length - 1; i >= 0; i -= 1) {
1365
- const start = itemStarts[i];
1366
- const end = i + 1 < itemStarts.length ? itemStarts[i + 1] : blockEnd;
1367
- const dashLead = lines[start]?.match(/^([ \t]*)-/)?.[1] ?? "";
1368
- const keyIndent = `${dashLead} `;
1369
- let found = false;
1370
- for (let index = start; index < end; index += 1) {
1371
- const line = lines[index];
1372
- if (line === undefined)
1373
- continue;
1374
- const isStart = index === start;
1375
- const prefixMatch = isStart ? line.match(/^([ \t]*-[ \t]*)(.*)$/) : line.match(/^([ \t]*)(.*)$/);
1376
- const prefix = prefixMatch?.[1] ?? "";
1377
- const content = prefixMatch?.[2] ?? "";
1378
- if (prefix.length !== keyIndent.length)
1379
- continue;
1380
- const withValue = content.match(/^(enabled:[ \t]*)([^\s#\r\n][^\r\n]*?)([ \t]*(?:#[^\r\n]*))?$/);
1381
- if (withValue) {
1382
- lines[index] = `${prefix}${withValue[1]}${enabled}${withValue[3] ?? ""}`;
1383
- found = true;
1384
- continue;
1385
- }
1386
- const bare = content.match(/^(enabled:)[ \t]*$/);
1387
- if (bare) {
1388
- lines[index] = `${prefix}${bare[1]} ${enabled}`;
1389
- found = true;
1390
- }
1391
- }
1392
- if (!found) {
1393
- lines.splice(start + 1, 0, `${keyIndent}enabled: ${enabled}`);
1394
- }
1395
- }
1396
- return `${lines.join("\n").trimEnd()}\n`;
1397
- }
1398
1443
  // Re-exported so tests can verify the validator path directly.
1399
1444
  // Re-export error classes consumed by callers that want to instanceof-check.
1400
1445
  // Re-export this so the CLI can decide what process exit code to use after