akm-cli 0.9.4 → 0.9.6

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 (123) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/dist/commands/env/env-cli.js +1 -2
  3. package/dist/commands/env/secret-cli.js +1 -5
  4. package/dist/commands/feedback-cli.js +0 -4
  5. package/dist/commands/health/checks.js +0 -32
  6. package/dist/commands/health/surfaces.js +2 -2
  7. package/dist/commands/health.js +6 -15
  8. package/dist/commands/improve/anti-collapse.js +4 -91
  9. package/dist/commands/improve/autonomy-gate.js +1 -1
  10. package/dist/commands/improve/consolidate.js +25 -26
  11. package/dist/commands/improve/distill.js +2 -2
  12. package/dist/commands/improve/extract.js +8 -1
  13. package/dist/commands/improve/improve.js +3 -5
  14. package/dist/commands/improve/locks.js +13 -5
  15. package/dist/commands/improve/loop-stages.js +8 -9
  16. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  17. package/dist/commands/improve/outcome-loop.js +0 -6
  18. package/dist/commands/improve/reflect.js +20 -9
  19. package/dist/commands/lint/index.js +4 -6
  20. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  21. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  22. package/dist/commands/read/search.js +14 -24
  23. package/dist/commands/sources/bundle-config-ops.js +9 -4
  24. package/dist/commands/sources/info.js +14 -10
  25. package/dist/commands/tasks/tasks-cli.js +56 -3
  26. package/dist/commands/tasks/tasks.js +101 -18
  27. package/dist/commands/workflow-cli.js +2 -2
  28. package/dist/core/adapter/adapters/akm-adapter.js +2 -0
  29. package/dist/core/common.js +61 -24
  30. package/dist/core/config/config-io.js +2 -2
  31. package/dist/core/config/config-sources.js +32 -2
  32. package/dist/core/config/config-version-shim.js +101 -0
  33. package/dist/core/config/config.js +7 -7
  34. package/dist/core/config/schema/engines.js +9 -7
  35. package/dist/core/config/schema/primitives.js +0 -5
  36. package/dist/core/config/schema/search.js +2 -1
  37. package/dist/core/file-lock.js +2 -1
  38. package/dist/core/improve-result.js +35 -14
  39. package/dist/core/maintenance-barrier.js +2 -14
  40. package/dist/core/paths.js +0 -3
  41. package/dist/core/redaction.js +2 -2
  42. package/dist/core/spawn-env.js +8 -12
  43. package/dist/core/state/migrations.js +1 -12
  44. package/dist/core/state-db.js +9 -27
  45. package/dist/core/write-source.js +12 -19
  46. package/dist/execution/directory-identity.js +36 -10
  47. package/dist/execution/guarded-source.js +0 -10
  48. package/dist/indexer/graph/graph-boost.js +0 -4
  49. package/dist/indexer/index-writer-lock.js +43 -24
  50. package/dist/indexer/index-written-assets.js +5 -6
  51. package/dist/indexer/indexer.js +2 -39
  52. package/dist/indexer/materialize-embeddings.js +85 -41
  53. package/dist/indexer/passes/metadata.js +12 -4
  54. package/dist/indexer/scan/doc-to-entry.js +2 -0
  55. package/dist/indexer/search/db-search.js +15 -48
  56. package/dist/indexer/search/ranking-contributors.js +0 -25
  57. package/dist/indexer/search/ranking.js +3 -13
  58. package/dist/indexer/search/search-fields.js +16 -1
  59. package/dist/integrations/agent/builder-shared.js +0 -25
  60. package/dist/integrations/agent/model-map.js +2 -60
  61. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  62. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  63. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  64. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  65. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  66. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  67. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  68. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  69. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  70. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  71. package/dist/integrations/session-logs/index.js +0 -9
  72. package/dist/llm/client.js +75 -42
  73. package/dist/llm/embedder.js +7 -3
  74. package/dist/llm/embedders/remote.js +141 -42
  75. package/dist/output/shapes/passthrough.js +17 -5
  76. package/dist/registry/network.js +5 -37
  77. package/dist/runtime.js +2 -10
  78. package/dist/scripts/akm-migrate-node.js +180 -238
  79. package/dist/scripts/akm-migrate.js +180 -238
  80. package/dist/setup/engine-config.js +2 -5
  81. package/dist/setup/registry-stash-loader.js +0 -8
  82. package/dist/setup/setup.js +9 -46
  83. package/dist/setup/steps/connection-shared.js +10 -13
  84. package/dist/sources/providers/git-install.js +1 -1
  85. package/dist/storage/engines/sqlite-migrations.js +20 -1
  86. package/dist/storage/repositories/index-entries-repository.js +0 -15
  87. package/dist/storage/repositories/proposals-repository.js +32 -6
  88. package/dist/tasks/backends/launchd.js +15 -20
  89. package/dist/tasks/backends/schtasks.js +18 -8
  90. package/dist/tasks/run/run-native-task.js +8 -6
  91. package/dist/tasks/scheduler-binding.js +15 -5
  92. package/dist/tasks/scheduler-sync-preview.js +4 -2
  93. package/dist/tasks/scheduler-sync.js +77 -42
  94. package/dist/tasks/source/bounded-document.js +2 -4
  95. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  96. package/dist/tasks/source/task-source-v4.js +5 -10
  97. package/dist/tasks/source/task-to-v3.js +29 -22
  98. package/dist/tasks/source/task-to-v4.js +1 -14
  99. package/dist/tasks/source-v3.js +6 -6
  100. package/dist/workflows/exec/native-executor.js +21 -31
  101. package/dist/workflows/exec/run-workflow.js +5 -6
  102. package/dist/workflows/exec/scheduler.js +3 -19
  103. package/dist/workflows/exec/step-work.js +1 -4
  104. package/dist/workflows/exec/unit-dispatch.js +2 -2
  105. package/dist/workflows/exec/worktree.js +1 -13
  106. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  107. package/dist/workflows/ir/plan-hash.js +4 -6
  108. package/dist/workflows/ir/schema-v4.js +0 -12
  109. package/dist/workflows/ir/schema.js +20 -31
  110. package/dist/workflows/parser.js +11 -52
  111. package/dist/workflows/renderer.js +2 -3
  112. package/dist/workflows/resource-limits.js +11 -41
  113. package/dist/workflows/runtime/runs.js +3 -4
  114. package/dist/workflows/source-ir/schema.js +14 -30
  115. package/dist/workflows/validator.js +1 -7
  116. package/docs/reference/cli.md +25 -3
  117. package/docs/reference/configuration.md +28 -7
  118. package/docs/reference/tasks.md +10 -0
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +4 -23
  121. package/schemas/akm-task.json +1 -2
  122. package/schemas/akm-workflow.json +1 -13
  123. package/dist/indexer/search/semantic-status.js +0 -142
@@ -35,11 +35,10 @@ import { exitCodeForStatus } from "../../tasks/run/task-result.js";
35
35
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
36
36
  import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
37
37
  import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
38
- import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
39
- import { renderSchedulerSyncPlanPreview } from "../../tasks/scheduler-sync-preview.js";
38
+ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
39
+ import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
40
40
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
41
41
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
42
- import { TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
43
42
  import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
44
43
  import { applyAutonomyGate, configuredDirectAutonomyLanes, describeGatedLanes } from "../improve/autonomy-gate.js";
45
44
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
@@ -417,6 +416,7 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
417
416
  unchanged: [...plan.unchanged],
418
417
  skipped: [],
419
418
  backend: sched.name,
419
+ failed: plan.failures.map((failure) => ({ ...failure })),
420
420
  ...(warnings.length > 0 ? { warnings } : {}),
421
421
  };
422
422
  }
@@ -434,6 +434,92 @@ export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
434
434
  const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
435
435
  return renderSchedulerSyncPlanPreview(sched.name, plan);
436
436
  }
437
+ /**
438
+ * Classify one installed scheduler binding as a prune candidate (#851), using
439
+ * the same two signals `doctor`'s `inspectInstalledBinding` already computes
440
+ * — deliberately narrower than that function's full `status` set. Only an
441
+ * entry whose ownership can NEVER be resolved (`invalid-context`) or whose
442
+ * resolved owner no longer exists on disk (`dead-bundle-path`) is a
443
+ * candidate; `missing-path` (e.g. the akm binary itself moved) is a
444
+ * different failure mode and is intentionally NOT folded in here, per the
445
+ * scoping in #851 — an entry that still resolves to a live bundle is never a
446
+ * candidate, full stop.
447
+ */
448
+ function classifyPruneCandidate(entry) {
449
+ let ownerBundlePath;
450
+ try {
451
+ ownerBundlePath = validateSchedulerContextDescriptor(entry.contextPath).environment.AKM_BUNDLE_DIR;
452
+ }
453
+ catch {
454
+ return "invalid-context";
455
+ }
456
+ if (ownerBundlePath !== undefined && !fs.existsSync(ownerBundlePath))
457
+ return "dead-bundle-path";
458
+ return undefined;
459
+ }
460
+ async function buildTaskPrunePlan(deps = {}, options = {}) {
461
+ const sched = deps.backend ?? selectBackend();
462
+ if (!sched.inspectBindings) {
463
+ throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for prune.`, "INVALID_CONFIG_FILE");
464
+ }
465
+ const inspection = await sched.inspectBindings({});
466
+ const candidates = new Map();
467
+ for (const entry of inspection.installed) {
468
+ const reason = classifyPruneCandidate(entry);
469
+ if (reason)
470
+ candidates.set(entry.id, reason);
471
+ }
472
+ const requestedIds = options.id?.filter((id) => id.length > 0) ?? [];
473
+ for (const id of requestedIds) {
474
+ if (!candidates.has(id)) {
475
+ throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
476
+ "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
477
+ }
478
+ }
479
+ const idFilter = requestedIds.length > 0 ? new Set(requestedIds) : undefined;
480
+ const resolved = resolveTaskReadBundle(undefined, undefined);
481
+ const bundleContext = {
482
+ adapterId: resolved.source.adapterId ?? detectAdapterId(resolved.source.path),
483
+ bundleName: resolved.source.name,
484
+ };
485
+ const operations = [];
486
+ for (const entry of inspection.installed) {
487
+ const reason = candidates.get(entry.id);
488
+ if (!reason)
489
+ continue;
490
+ if (idFilter && !idFilter.has(entry.id))
491
+ continue;
492
+ const operation = buildSchedulerRemoveOperation(entry.id, entry, inspection.artifacts, bundleContext);
493
+ operations.push(Object.freeze({ ...operation, reason }));
494
+ }
495
+ return { sched, operations: Object.freeze(operations) };
496
+ }
497
+ /**
498
+ * `akm task prune` (#851): remove installed scheduler bindings `sync` can
499
+ * never reclaim because their own `--scheduler-context` descriptor doesn't
500
+ * resolve to a live bundle. Defaults to dry-run — no `--yes` and no `--id`
501
+ * means zero backend calls that could mutate anything, matching
502
+ * `akmTasksSyncPlan`'s zero-write guarantee. `--id` (one or more) narrows
503
+ * execution to exactly those bindings; `--yes` alone executes every
504
+ * currently-computed candidate. Both still return the full preview so the
505
+ * plan is never silent about what it did.
506
+ */
507
+ export async function akmTasksPrune(deps = {}, options = {}) {
508
+ const { sched, operations } = await buildTaskPrunePlan(deps, options);
509
+ const preview = renderSchedulerPlanPreview(sched.name, operations);
510
+ if (!options.yes) {
511
+ return { backend: sched.name, dryRun: true, preview, removed: [] };
512
+ }
513
+ await applySchedulerTransaction(sched, operations, {
514
+ initialExpectations: operations.map((operation) => operation.expected),
515
+ });
516
+ return {
517
+ backend: sched.name,
518
+ dryRun: false,
519
+ preview,
520
+ removed: operations.map((operation) => operation.id),
521
+ };
522
+ }
437
523
  export async function akmTasksDoctor(deps = {}) {
438
524
  const warnings = [];
439
525
  let invocation = {
@@ -884,13 +970,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
884
970
  if (!rootStat.isDirectory()) {
885
971
  throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
886
972
  }
887
- const common = {
888
- filePath,
889
- rootRealPath,
890
- rootPhysicalIdentity: rootStat.ino === 0n ? `path:${rootRealPath}` : `inode:${rootStat.dev}:${rootStat.ino}`,
891
- rootMtimeNs: String(rootStat.mtimeNs),
892
- rootCtimeNs: String(rootStat.ctimeNs),
893
- };
973
+ const common = { filePath, rootRealPath };
894
974
  let descriptor;
895
975
  try {
896
976
  const noFollow = "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
@@ -899,9 +979,6 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
899
979
  if (!before.isFile()) {
900
980
  throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
901
981
  }
902
- if (before.size > BigInt(TASK_V3_MAX_SOURCE_BYTES)) {
903
- throw new UsageError(`${filePath} exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) task source limit.`, "INVALID_FLAG_VALUE");
904
- }
905
982
  const bytes = fs.readFileSync(descriptor);
906
983
  const after = fs.fstatSync(descriptor, { bigint: true });
907
984
  if (!sameTaskSourceStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
@@ -923,10 +1000,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
923
1000
  state: "present",
924
1001
  ...common,
925
1002
  realPath,
926
- physicalIdentity: before.ino === 0n ? `path:${realPath}` : `inode:${before.dev}:${before.ino}`,
927
1003
  size: bytes.byteLength,
928
- mtimeNs: String(before.mtimeNs),
929
- ctimeNs: String(before.ctimeNs),
930
1004
  sha256: createHash("sha256").update(bytes).digest("hex"),
931
1005
  bytesBase64: bytes.toString("base64"),
932
1006
  content,
@@ -948,6 +1022,11 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
948
1022
  fs.closeSync(descriptor);
949
1023
  }
950
1024
  }
1025
+ // TOCTOU note: this compares CONTENT (state + sha256), not filesystem
1026
+ // identity (inode/mtime/ctime/directory timestamps) — the same split already
1027
+ // applied to the task migrator in 0.9.5. An unrelated touch to the file or
1028
+ // its containing directory must not trip a "changed after planning" refusal;
1029
+ // only a real content change should.
951
1030
  function assertTaskSourceExpectation(expected) {
952
1031
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
953
1032
  if (!sameTaskSourceExpectation(actual, expected)) {
@@ -955,12 +1034,16 @@ function assertTaskSourceExpectation(expected) {
955
1034
  }
956
1035
  }
957
1036
  function sameTaskSourceExpectation(left, right) {
958
- return JSON.stringify(left) === JSON.stringify(right);
1037
+ if (left.state !== right.state || left.filePath !== right.filePath || left.rootRealPath !== right.rootRealPath) {
1038
+ return false;
1039
+ }
1040
+ if (left.state === "absent")
1041
+ return true;
1042
+ return left.sha256 === right.sha256;
959
1043
  }
960
1044
  function assertTaskSourceRestored(expected) {
961
1045
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
962
1046
  const restored = actual.state === expected.state &&
963
- actual.rootPhysicalIdentity === expected.rootPhysicalIdentity &&
964
1047
  (actual.state === "absent" ||
965
1048
  (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
966
1049
  if (!restored) {
@@ -20,7 +20,7 @@ import { deliverRendered } from "../output/html-render.js";
20
20
  import { shapeForCommand } from "../output/shapes.js";
21
21
  import { formatPlain } from "../output/text.js";
22
22
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
23
- import { WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
23
+ import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
24
24
  import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
25
25
  import { akmWorkflowPlan } from "./workflow/plan.js";
26
26
  const workflowStatusCommand = defineJsonCommand({
@@ -168,7 +168,7 @@ const workflowRunCommand = defineJsonCommand({
168
168
  const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
169
169
  const parameterFlags = parseWorkflowParameterFlags(rawArgs, args.target);
170
170
  const maxSteps = parseIntegerFlag(getStringArg(args, "max-steps"), "--max-steps", 1);
171
- const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0, WORKFLOW_MAX_RETRIES);
171
+ const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0);
172
172
  const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
173
173
  const controller = new AbortController();
174
174
  let signalExitCode;
@@ -223,6 +223,8 @@ function indexDocumentFromEntry(entry, base, rendererName) {
223
223
  doc.tags = entry.tags;
224
224
  if (entry.content !== undefined)
225
225
  doc.content = entry.content;
226
+ if (entry.contentTruncated !== undefined)
227
+ doc.contentTruncated = entry.contentTruncated;
226
228
  if (entry.aliases !== undefined)
227
229
  doc.aliases = entry.aliases;
228
230
  if (entry.searchHints !== undefined)
@@ -10,8 +10,6 @@ import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryInde
10
10
  // Moved to the platform leaf so paths.ts can use it without a common↔paths
11
11
  // cycle (chunk-8 WI-8.6, DoD 11); re-exported here for the existing surface.
12
12
  export { IS_WINDOWS } from "./platform.js";
13
- export const MAX_CONFIG_FILE_BYTES = 1024 * 1024;
14
- export const MAX_LOCAL_METADATA_BYTES = 1024 * 1024;
15
13
  export const MAX_LOCK_METADATA_BYTES = 64 * 1024;
16
14
  export function isHttpUrl(value) {
17
15
  return !!value && /^https?:\/\//.test(value);
@@ -51,10 +49,13 @@ export function readTextFileDescriptorWithLimit(fd, maxBytes, label = "File", di
51
49
  }
52
50
  return buffer.subarray(0, total).toString("utf8");
53
51
  }
54
- export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
52
+ export function readTextFile(filePath, label = "File") {
55
53
  const fd = fs.openSync(filePath, "r");
56
54
  try {
57
- return readTextFileDescriptorWithLimit(fd, maxBytes, label, filePath);
55
+ const stat = fs.fstatSync(fd);
56
+ if (!stat.isFile())
57
+ throw new ConfigError(`${label} is not a regular file: ${filePath}.`, "INVALID_CONFIG_FILE");
58
+ return fs.readFileSync(fd, "utf8");
58
59
  }
59
60
  finally {
60
61
  fs.closeSync(fd);
@@ -290,7 +291,7 @@ function isValidDirectory(dir) {
290
291
  function readStashDirFromConfig() {
291
292
  try {
292
293
  const configPath = getConfigPath();
293
- const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
294
+ const text = readTextFile(configPath, "Config file");
294
295
  // The config loader accepts JSONC, so a commented config.json is valid and
295
296
  // in use. Parsing it raw here threw, the catch swallowed it, and every
296
297
  // caller silently fell back — operating on the wrong bundle or failing with
@@ -544,21 +545,59 @@ export async function fetchWithTimeout(url, opts, timeoutMs = 30_000, signal) {
544
545
  clearTimeout(timer);
545
546
  }
546
547
  }
548
+ /**
549
+ * Cap on how long a retry loop will wait between attempts, even when a
550
+ * server-supplied `Retry-After` claims a longer delay. Prevents an
551
+ * attacker-controlled or misconfigured server from parking a caller
552
+ * indefinitely.
553
+ */
554
+ export const DEFAULT_RETRY_MAX_DELAY_MS = 30_000;
555
+ export function shouldRetry(status) {
556
+ return status === 429 || status >= 500;
557
+ }
558
+ /** Jittered exponential backoff, capped at `maxDelayMs`. */
559
+ export function backoffDelay(attempt, baseDelay = 500, maxDelayMs = DEFAULT_RETRY_MAX_DELAY_MS) {
560
+ return Math.min(maxDelayMs, baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5));
561
+ }
562
+ /**
563
+ * Determine the delay before the next retry attempt.
564
+ *
565
+ * Honors a server-supplied `Retry-After` header in both its numeric-seconds
566
+ * and HTTP-date forms, but always clamps the result to `maxDelayMs` — an
567
+ * unclamped `Retry-After` lets an attacker/misconfigured server park a
568
+ * caller for an arbitrarily long time. Falls back to jittered exponential
569
+ * backoff when the header is absent or unparseable.
570
+ */
571
+ export function computeRetryDelay(response, attempt, options) {
572
+ const maxDelayMs = options?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
573
+ const baseDelay = options?.baseDelay ?? 500;
574
+ const retryAfter = response.headers.get("retry-after");
575
+ if (retryAfter) {
576
+ const seconds = Number(retryAfter);
577
+ if (Number.isFinite(seconds)) {
578
+ return seconds >= 0 ? Math.min(maxDelayMs, seconds * 1_000) : backoffDelay(attempt, baseDelay, maxDelayMs);
579
+ }
580
+ const date = Date.parse(retryAfter);
581
+ if (Number.isFinite(date))
582
+ return Math.min(maxDelayMs, Math.max(0, date - Date.now()));
583
+ }
584
+ return backoffDelay(attempt, baseDelay, maxDelayMs);
585
+ }
547
586
  /**
548
587
  * Fetch with retry and exponential backoff.
549
588
  * Retries on network errors, 429, and 5xx responses.
550
- * Honors Retry-After header for 429 responses.
589
+ * Honors Retry-After header, capped at `maxDelayMs`.
551
590
  */
552
591
  export async function fetchWithRetry(url, init, options) {
553
592
  const maxRetries = options?.retries ?? 3;
554
593
  const baseDelay = options?.baseDelay ?? 500;
594
+ const maxDelayMs = options?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
555
595
  const timeout = options?.timeout ?? 30_000;
556
596
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
557
597
  try {
558
598
  const response = await fetchWithTimeout(url, init, timeout, init?.signal ?? undefined);
559
599
  if (attempt < maxRetries && shouldRetry(response.status)) {
560
- const retryAfter = parseRetryAfter(response);
561
- const delay = retryAfter ?? baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
600
+ const delay = computeRetryDelay(response, attempt, { baseDelay, maxDelayMs });
562
601
  await response.body?.cancel().catch(() => undefined);
563
602
  await abortableDelay(delay, init?.signal);
564
603
  continue;
@@ -571,8 +610,7 @@ export async function fetchWithRetry(url, init, options) {
571
610
  // A caller-supplied abort is terminal: never keep retrying past it.
572
611
  if (init?.signal?.aborted)
573
612
  throw err;
574
- const delay = baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
575
- await abortableDelay(delay, init?.signal);
613
+ await abortableDelay(backoffDelay(attempt, baseDelay, maxDelayMs), init?.signal);
576
614
  }
577
615
  }
578
616
  throw new Error("fetchWithRetry: unreachable");
@@ -584,17 +622,19 @@ export async function fetchWithRetry(url, init, options) {
584
622
  * large. Sleeping it out with a bare `setTimeout` ignored the caller's abort
585
623
  * signal entirely, so a single `429` could park an operation far past any
586
624
  * deadline its caller believed it had imposed — the request timeout bounds
587
- * only the request, never the wait between attempts.
625
+ * only the request, never the wait between attempts. Callers using this for
626
+ * a retry delay should pass a `maxDelayMs`-capped `ms` (see
627
+ * {@link computeRetryDelay}) to bound the wait itself.
588
628
  */
589
- function abortableDelay(ms, signal) {
629
+ export function abortableDelay(ms, signal, abortMessage = "Aborted") {
590
630
  if (!signal)
591
631
  return new Promise((resolve) => setTimeout(resolve, ms));
592
632
  if (signal.aborted)
593
- return Promise.reject(signal.reason ?? new Error("Aborted"));
633
+ return Promise.reject(signal.reason ?? new Error(abortMessage));
594
634
  return new Promise((resolve, reject) => {
595
635
  const onAbort = () => {
596
636
  clearTimeout(timer);
597
- reject(signal.reason ?? new Error("Aborted"));
637
+ reject(signal.reason ?? new Error(abortMessage));
598
638
  };
599
639
  const timer = setTimeout(() => {
600
640
  signal.removeEventListener("abort", onAbort);
@@ -603,9 +643,6 @@ function abortableDelay(ms, signal) {
603
643
  signal.addEventListener("abort", onAbort, { once: true });
604
644
  });
605
645
  }
606
- function shouldRetry(status) {
607
- return status === 429 || status >= 500;
608
- }
609
646
  /**
610
647
  * Read stdin as UTF-8 text if something is piped in. Returns `undefined`
611
648
  * when stdin is a TTY (no pipe) or when the piped content is empty.
@@ -789,13 +826,6 @@ export async function jsonWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE
789
826
  const text = await readBodyWithByteCap(response, maxBytes, limits);
790
827
  return JSON.parse(text);
791
828
  }
792
- function parseRetryAfter(response) {
793
- const header = response.headers.get("retry-after");
794
- if (!header)
795
- return undefined;
796
- const seconds = parseInt(header, 10);
797
- return Number.isNaN(seconds) ? undefined : seconds * 1000;
798
- }
799
829
  export function toErrorMessage(error) {
800
830
  return error instanceof Error ? error.message : String(error);
801
831
  }
@@ -871,8 +901,15 @@ export function stringArray(value) {
871
901
  * reported alive. Treating it as dead let a lock held by a live process in a
872
902
  * shared data dir (agent sandboxes, containers, service accounts — a
873
903
  * configuration managed-db.ts explicitly supports) be reclaimed as stale.
904
+ *
905
+ * `pid` is `unknown` because callers reading it out of untrusted on-disk
906
+ * JSON (e.g. a lease file) cannot guarantee it parsed as a valid PID; a
907
+ * non-positive-integer value is reported dead without ever reaching
908
+ * `process.kill`.
874
909
  */
875
910
  export function isProcessAlive(pid) {
911
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
912
+ return false;
876
913
  try {
877
914
  process.kill(pid, 0);
878
915
  return true;
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
- import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, stripJsonComments, writeFileAtomic } from "../common.js";
18
+ import { readTextFile, stripJsonComments, writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
20
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
@@ -25,7 +25,7 @@ import { getCacheDir, getConfigDir } from "../paths.js";
25
25
  */
26
26
  export function readConfigText(configPath) {
27
27
  try {
28
- return readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
28
+ return readTextFile(configPath, "Config file");
29
29
  }
30
30
  catch (err) {
31
31
  if (err.code === "ENOENT")
@@ -23,6 +23,16 @@ export function bundleComponentConfig(bundle) {
23
23
  * `defaultBundle` first, then map insertion order. Each entry's `name` is its
24
24
  * bundle key. Returns `undefined` when no bundles map is configured.
25
25
  */
26
+ /**
27
+ * A bundle's true identity: its configured `path` plus its component's
28
+ * `root` (default `"."`), fully resolved. Two bundle entries whose bare
29
+ * `path` differs (relative vs. absolute, trailing slash, `~` vs. expanded)
30
+ * can still resolve to this same directory — this is the identity akm
31
+ * compares before registering or reconciling a bundle (issue #870).
32
+ */
33
+ export function bundleContentRoot(entryPath, componentRoot) {
34
+ return path.resolve(entryPath, componentRoot ?? ".");
35
+ }
26
36
  /**
27
37
  * The resolved primary stash path — the `defaultBundle`'s filesystem `path`
28
38
  * (spec §10.1) — or `undefined` when no filesystem primary is configured.
@@ -36,9 +46,9 @@ export function primaryBundlePath(config) {
36
46
  if (!entry || typeof entry.path !== "string" || entry.path.length === 0)
37
47
  return undefined;
38
48
  const componentRoot = bundleComponentConfig(entry)?.root;
39
- if (!componentRoot || componentRoot === ".")
40
- return entry.path;
41
49
  const bundleRoot = path.resolve(entry.path);
50
+ if (!componentRoot || componentRoot === ".")
51
+ return bundleRoot;
42
52
  const resolved = path.resolve(bundleRoot, componentRoot);
43
53
  const relative = path.relative(bundleRoot, resolved);
44
54
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
@@ -46,6 +56,26 @@ export function primaryBundlePath(config) {
46
56
  }
47
57
  return resolved;
48
58
  }
59
+ /**
60
+ * Every configured filesystem bundle's id paired with its resolved content
61
+ * root. Used to detect two bundle ids that resolve to the same directory
62
+ * (issue #870) and to find the id that already owns a given root before a
63
+ * new one is registered.
64
+ */
65
+ export function bundleContentRoots(config) {
66
+ const bundles = config.bundles ?? {};
67
+ const out = [];
68
+ for (const [id, entry] of Object.entries(bundles)) {
69
+ if (typeof entry.path !== "string" || entry.path.length === 0)
70
+ continue;
71
+ out.push({ id, contentRoot: bundleContentRoot(entry.path, bundleComponentConfig(entry)?.root) });
72
+ }
73
+ return out;
74
+ }
75
+ /** The bundle id whose resolved content root already matches `resolvedContentRoot`, if any. */
76
+ export function bundleKeyForContentRoot(config, resolvedContentRoot) {
77
+ return bundleContentRoots(config).find((entry) => entry.contentRoot === resolvedContentRoot)?.id;
78
+ }
49
79
  export function bundlesToSourceEntries(config) {
50
80
  const bundles = config.bundles;
51
81
  if (!bundles)
@@ -0,0 +1,101 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The `configVersion` read shim (#863).
6
+ *
7
+ * `configVersion` used to be a hard `z.literal(CURRENT_CONFIG_VERSION)` gate:
8
+ * every value other than the exact current string threw
9
+ * `UNSUPPORTED_CONFIG_VERSION` for every command, since every akm invocation
10
+ * loads config first. That is the same shape of break #858/#859 (proposal
11
+ * rows), the `task_history` `metadataVersion` gate, and the task-source v2/v3
12
+ * gate each caused in 0.9.x — a version gate with no read shim — except
13
+ * `configVersion`'s blast radius is the whole CLI rather than one subsystem.
14
+ *
15
+ * This module mirrors the in-tree template for that fix,
16
+ * `src/tasks/source/parse-task-source.ts`'s v2/v3 -> v4 shim: a known-old
17
+ * version routes through a pure, in-memory upgrade function to the current
18
+ * shape, with a one-line stderr deprecation warning; the result is never
19
+ * written back to disk (the on-disk rewrite already happens for free — every
20
+ * `saveConfig`/`mutateConfig` write forces `configVersion` to
21
+ * {@link CURRENT_CONFIG_VERSION}, so the very next `akm config set` or any
22
+ * other mutating command silences the warning permanently). A version that is
23
+ * neither current nor a known old version — including anything NEWER than
24
+ * current — still fails closed with `UNSUPPORTED_CONFIG_VERSION`:
25
+ * forward-incompatibility is a real hazard (an older binary must not guess at
26
+ * a newer, unknown shape) and this shim does not soften that.
27
+ *
28
+ * IMPORTANT — as of this writing, `"0.9.0"` is the only `configVersion` akm
29
+ * has ever shipped; there is no real prior release to shim. `"0.0.1"` below
30
+ * is a SYNTHETIC placeholder entry that exists solely to stand up and
31
+ * exercise this mechanism — the known-versions list, the dispatch table, the
32
+ * warn-once-and-upgrade behavior, the fail-closed behavior for anything
33
+ * else — before a real bump ever needs it (see
34
+ * `tests/integration/config-version-shim.test.ts` and the
35
+ * `previous-release-corpus.test.ts` fixture). When the first genuine
36
+ * `configVersion` bump ships, add its real old shape as its own entry the
37
+ * same way and delete the synthetic `"0.0.1"` entry (and this paragraph) in
38
+ * the same change.
39
+ */
40
+ import { ConfigError } from "../errors.js";
41
+ import { warn } from "../warn.js";
42
+ import { CURRENT_CONFIG_VERSION } from "./schema/primitives.js";
43
+ /**
44
+ * Every `configVersion` this binary can still READ, other than
45
+ * {@link CURRENT_CONFIG_VERSION} itself — each with an in-memory upgrade
46
+ * function in {@link CONFIG_VERSION_UPGRADES}. Anything not in this list (and
47
+ * not equal to current) fails closed.
48
+ */
49
+ export const KNOWN_OLD_CONFIG_VERSIONS = ["0.0.1"];
50
+ function isKnownOldConfigVersion(value) {
51
+ return typeof value === "string" && KNOWN_OLD_CONFIG_VERSIONS.includes(value);
52
+ }
53
+ /**
54
+ * SYNTHETIC 0.0.1 -> 0.9.0 upgrade (placeholder — see module doc). Per this
55
+ * placeholder, 0.0.1 kept the default LLM engine name at the config root as
56
+ * `defaultEngine`; 0.9.0 moved it under `defaults.llmEngine`. Pure function:
57
+ * takes the raw parsed JSON object, returns a new raw object with the 0.9.0
58
+ * shape. Never touches disk.
59
+ */
60
+ function upgradeFrom080(raw) {
61
+ const { defaultEngine, defaults, ...rest } = raw;
62
+ if (typeof defaultEngine !== "string" || defaultEngine.length === 0) {
63
+ return { ...rest, ...(defaults !== undefined ? { defaults } : {}), configVersion: CURRENT_CONFIG_VERSION };
64
+ }
65
+ const existingDefaults = defaults !== null && typeof defaults === "object" ? defaults : {};
66
+ return {
67
+ ...rest,
68
+ configVersion: CURRENT_CONFIG_VERSION,
69
+ // An explicit `defaults.llmEngine` already present in the raw 0.0.1
70
+ // document (should never happen for a real 0.0.1 file, but a malformed
71
+ // one is possible) wins over the root-level field being migrated in.
72
+ defaults: { llmEngine: defaultEngine, ...existingDefaults },
73
+ };
74
+ }
75
+ const CONFIG_VERSION_UPGRADES = {
76
+ "0.0.1": upgradeFrom080,
77
+ };
78
+ function unsupportedConfigVersionError(rawVersion, sourcePath) {
79
+ const where = sourcePath ? ` at ${sourcePath}` : "";
80
+ const supported = [CURRENT_CONFIG_VERSION, ...KNOWN_OLD_CONFIG_VERSIONS].map((v) => `"${v}"`).join(", ");
81
+ return new ConfigError(`Unsupported configVersion${where}: got ${JSON.stringify(rawVersion)}, expected one of ${supported}.`, "UNSUPPORTED_CONFIG_VERSION", "Recreate engines and improve.strategies manually for AKM 0.9.0; profile-based configuration is not translated automatically.");
82
+ }
83
+ /**
84
+ * Route a raw parsed config object through the version shim before schema
85
+ * validation. Returns a raw object whose `configVersion` is
86
+ * {@link CURRENT_CONFIG_VERSION} — either unchanged (already current),
87
+ * in-memory-upgraded (a known old version, with a one-line stderr warning),
88
+ * or this throws `UNSUPPORTED_CONFIG_VERSION` (unknown, newer, missing, or
89
+ * malformed).
90
+ */
91
+ export function upgradeConfigVersion(raw, sourcePath) {
92
+ const version = raw.configVersion;
93
+ if (version === CURRENT_CONFIG_VERSION)
94
+ return raw;
95
+ if (isKnownOldConfigVersion(version)) {
96
+ const upgraded = CONFIG_VERSION_UPGRADES[version](raw);
97
+ warn(`Config${sourcePath ? ` at ${sourcePath}` : ""} uses configVersion "${version}" — auto-upgraded to ${CURRENT_CONFIG_VERSION} in memory; the next config write (e.g. \`akm config set\`) persists this and silences the warning.`);
98
+ return upgraded;
99
+ }
100
+ throw unsupportedConfigVersionError(version, sourcePath);
101
+ }
@@ -8,6 +8,7 @@ import { liftLegacyEngineExtraParams } from "../extra-params.js";
8
8
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
9
9
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
10
10
  import { bundlesToSourceEntries } from "./config-sources.js";
11
+ import { upgradeConfigVersion } from "./config-version-shim.js";
11
12
  import { deepMergeConfig } from "./deep-merge.js";
12
13
  export { stripJsonComments } from "./config-io.js";
13
14
  import { getConfigPath } from "../paths.js";
@@ -138,14 +139,13 @@ export function acquireConfigReadFence() {
138
139
  * Parse raw config text and validate via Zod.
139
140
  * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
140
141
  *
141
- * The schema accepts only the current config version and validates the
142
- * canonical shape before defaults are merged.
142
+ * The schema accepts only the current config version. A known older version
143
+ * is auto-upgraded in memory first (see `./config-version-shim`); anything
144
+ * else — including anything newer — is rejected before the canonical shape
145
+ * is validated.
143
146
  */
144
147
  export function parseAndValidateConfigText(text, sourcePath) {
145
- const parsedRaw = parseConfigText(text, sourcePath);
146
- if (parsedRaw.configVersion !== CURRENT_CONFIG_VERSION) {
147
- throw new ConfigError(`Unsupported configVersion${sourcePath ? ` at ${sourcePath}` : ""}: expected "${CURRENT_CONFIG_VERSION}".`, "UNSUPPORTED_CONFIG_VERSION", "Recreate engines and improve.strategies manually for AKM 0.9.0; profile-based configuration is not translated automatically.");
148
- }
148
+ const parsedRaw = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
149
149
  // #852 (following #815): lift legacy `extraParams` keys — e.g.
150
150
  // `reasoning_effort`, a documented 0.9.1 workaround — onto the first-class
151
151
  // engine field they now shadow, before the protected-key check in
@@ -376,7 +376,7 @@ export function getIndexPassConfig(config, passName) {
376
376
  return entry;
377
377
  }
378
378
  // Re-export source runtime helpers — implementation lives in config-sources.ts.
379
- export { bundleComponentConfig, bundleEntryToSourceEntry, bundlesToSourceEntries, installedSourceDescriptor, parseSourceSpec, primaryBundlePath, resolveConfiguredSources, } from "./config-sources.js";
379
+ export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlesToSourceEntries, installedSourceDescriptor, parseSourceSpec, primaryBundlePath, resolveConfiguredSources, } from "./config-sources.js";
380
380
  /**
381
381
  * Merge a partial user-config override onto a base config. Used by
382
382
  * {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
@@ -15,7 +15,7 @@ import { z } from "zod";
15
15
  // a config-schema ↔ config-types type cycle that collapses inference.
16
16
  import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
17
17
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
18
- import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, LlmCapabilitiesSchema, nonEmptyString, positiveInt, } from "./primitives.js";
18
+ import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, nonEmptyString, positiveInt, } from "./primitives.js";
19
19
  /**
20
20
  * Engine-config timeouts share the workflow ceiling.
21
21
  *
@@ -43,16 +43,20 @@ export const LlmConnectionConfigSchema = z
43
43
  maxTokens: positiveInt.optional(),
44
44
  timeoutMs: timeoutMsField,
45
45
  concurrency: positiveInt.optional(),
46
- capabilities: LlmCapabilitiesSchema.optional(),
46
+ // User-settable override, not a cached probe verdict: attempt-then-
47
+ // fallback in llm/client.ts tries `response_format: json_schema` whenever
48
+ // a schema is supplied, degrading to plain text on an unsupported-4xx and
49
+ // remembering that in-memory for the rest of the process. `false` here
50
+ // opts a known-incompatible endpoint out of even the first attempt;
51
+ // `true` is advisory only.
52
+ supportsJsonSchema: z.boolean().optional(),
47
53
  extraParams: ExtraParamsSchema.optional(),
48
54
  contextLength: positiveInt.optional(),
49
55
  enableThinking: z.boolean().optional(),
50
56
  reasoningEffort: nonEmptyString.optional(),
51
57
  })
52
58
  .passthrough();
53
- export const LlmProfileConfigSchema = LlmConnectionConfigSchema.extend({
54
- supportsJsonSchema: z.boolean().optional(),
55
- }).passthrough();
59
+ export const LlmProfileConfigSchema = LlmConnectionConfigSchema.passthrough();
56
60
  // ── Agent engines ───────────────────────────────────────────────────────────
57
61
  // Derives from the canonical VALID_HARNESS_IDS (#565) so the Zod gate cannot
58
62
  // drift from the TS union / parse check / setup detection.
@@ -68,7 +72,6 @@ const LlmEngineSchema = z
68
72
  maxTokens: positiveInt.optional(),
69
73
  timeoutMs: timeoutMsField,
70
74
  concurrency: positiveInt.optional(),
71
- supportsJsonSchema: z.boolean().optional(),
72
75
  extraParams: ExtraParamsSchema.optional(),
73
76
  contextLength: positiveInt.optional(),
74
77
  enableThinking: z.boolean().optional(),
@@ -103,7 +106,6 @@ const AgentEngineSchema = z
103
106
  "temperature",
104
107
  "maxTokens",
105
108
  "concurrency",
106
- "supportsJsonSchema",
107
109
  "extraParams",
108
110
  "contextLength",
109
111
  "enableThinking",
@@ -54,11 +54,6 @@ export const ExtraParamsSchema = z.record(z.unknown()).superRefine((value, ctx)
54
54
  }
55
55
  });
56
56
  // ── Shared connection/invocation building blocks ────────────────────────────
57
- export const LlmCapabilitiesSchema = z
58
- .object({
59
- structuredOutput: z.boolean().optional(),
60
- })
61
- .passthrough();
62
57
  export const LlmInvocationOverridesSchema = z
63
58
  .object({
64
59
  temperature: z.number().finite().optional(),