akm-cli 0.9.5 → 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 (105) hide show
  1. package/CHANGELOG.md +105 -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/autonomy-gate.js +1 -1
  9. package/dist/commands/improve/consolidate.js +25 -26
  10. package/dist/commands/improve/distill.js +2 -2
  11. package/dist/commands/improve/extract.js +8 -1
  12. package/dist/commands/improve/improve.js +3 -5
  13. package/dist/commands/improve/locks.js +13 -5
  14. package/dist/commands/improve/loop-stages.js +8 -9
  15. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  16. package/dist/commands/improve/outcome-loop.js +0 -6
  17. package/dist/commands/improve/reflect.js +20 -9
  18. package/dist/commands/lint/index.js +4 -6
  19. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  20. package/dist/commands/sources/bundle-config-ops.js +9 -4
  21. package/dist/commands/sources/info.js +14 -10
  22. package/dist/commands/tasks/tasks.js +12 -16
  23. package/dist/commands/workflow-cli.js +2 -2
  24. package/dist/core/common.js +61 -24
  25. package/dist/core/config/config-io.js +2 -2
  26. package/dist/core/config/config-sources.js +32 -2
  27. package/dist/core/config/config.js +1 -1
  28. package/dist/core/config/schema/engines.js +9 -7
  29. package/dist/core/config/schema/primitives.js +0 -5
  30. package/dist/core/config/schema/search.js +2 -1
  31. package/dist/core/file-lock.js +2 -1
  32. package/dist/core/maintenance-barrier.js +2 -14
  33. package/dist/core/paths.js +0 -3
  34. package/dist/core/redaction.js +2 -2
  35. package/dist/core/spawn-env.js +8 -12
  36. package/dist/core/state/migrations.js +1 -12
  37. package/dist/core/state-db.js +9 -27
  38. package/dist/core/write-source.js +12 -19
  39. package/dist/execution/directory-identity.js +36 -10
  40. package/dist/indexer/graph/graph-boost.js +0 -4
  41. package/dist/indexer/index-writer-lock.js +43 -24
  42. package/dist/indexer/index-written-assets.js +5 -6
  43. package/dist/indexer/indexer.js +2 -39
  44. package/dist/indexer/materialize-embeddings.js +85 -41
  45. package/dist/indexer/search/db-search.js +15 -48
  46. package/dist/indexer/search/ranking-contributors.js +0 -25
  47. package/dist/indexer/search/ranking.js +3 -13
  48. package/dist/integrations/agent/builder-shared.js +0 -25
  49. package/dist/integrations/agent/model-map.js +2 -60
  50. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  51. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  52. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  53. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  54. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  55. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  56. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  57. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  58. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  59. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  60. package/dist/integrations/session-logs/index.js +0 -9
  61. package/dist/llm/client.js +75 -42
  62. package/dist/llm/embedder.js +7 -3
  63. package/dist/llm/embedders/remote.js +141 -42
  64. package/dist/registry/network.js +5 -37
  65. package/dist/runtime.js +2 -10
  66. package/dist/scripts/akm-migrate-node.js +60 -126
  67. package/dist/scripts/akm-migrate.js +60 -126
  68. package/dist/setup/engine-config.js +2 -5
  69. package/dist/setup/registry-stash-loader.js +0 -8
  70. package/dist/setup/setup.js +9 -46
  71. package/dist/setup/steps/connection-shared.js +10 -13
  72. package/dist/sources/providers/git-install.js +1 -1
  73. package/dist/storage/engines/sqlite-migrations.js +20 -1
  74. package/dist/storage/repositories/index-entries-repository.js +0 -15
  75. package/dist/tasks/backends/launchd.js +15 -20
  76. package/dist/tasks/backends/schtasks.js +18 -8
  77. package/dist/tasks/run/run-native-task.js +8 -6
  78. package/dist/tasks/source/bounded-document.js +2 -4
  79. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  80. package/dist/tasks/source/task-source-v4.js +5 -10
  81. package/dist/tasks/source/task-to-v3.js +5 -10
  82. package/dist/tasks/source/task-to-v4.js +1 -4
  83. package/dist/tasks/source-v3.js +6 -6
  84. package/dist/workflows/exec/native-executor.js +21 -31
  85. package/dist/workflows/exec/run-workflow.js +5 -6
  86. package/dist/workflows/exec/scheduler.js +3 -19
  87. package/dist/workflows/exec/step-work.js +1 -4
  88. package/dist/workflows/exec/unit-dispatch.js +2 -2
  89. package/dist/workflows/exec/worktree.js +1 -13
  90. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  91. package/dist/workflows/ir/plan-hash.js +4 -6
  92. package/dist/workflows/ir/schema-v4.js +0 -12
  93. package/dist/workflows/ir/schema.js +20 -31
  94. package/dist/workflows/parser.js +11 -52
  95. package/dist/workflows/renderer.js +2 -3
  96. package/dist/workflows/resource-limits.js +11 -41
  97. package/dist/workflows/runtime/runs.js +3 -4
  98. package/dist/workflows/source-ir/schema.js +14 -30
  99. package/dist/workflows/validator.js +1 -7
  100. package/docs/reference/configuration.md +1 -1
  101. package/package.json +1 -1
  102. package/schemas/akm-config.json +4 -23
  103. package/schemas/akm-task.json +1 -2
  104. package/schemas/akm-workflow.json +1 -13
  105. package/dist/indexer/search/semantic-status.js +0 -142
@@ -44,7 +44,7 @@ import { prepareInlineExecution, prepareInlineExecutionWithRunner } from "../../
44
44
  import { buildReflectOutputRepairPrompt, buildReflectPrompt, extractDraftConfidence, parseAgentProposalPayload, } from "../../integrations/agent/prompts.js";
45
45
  import { runnerIsLlm, runnerSupportsFileWrite } from "../../integrations/agent/runner.js";
46
46
  import { collectDispatchSensitiveValues } from "../../integrations/agent/runner-dispatch.js";
47
- import { LlmCallError } from "../../llm/client.js";
47
+ import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js";
48
48
  import { callStructured } from "../../llm/structured-call.js";
49
49
  import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
50
50
  import { isProposalSkipped, listProposalsReadOnly, proposalContent, } from "../proposal/repository.js";
@@ -578,8 +578,9 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
578
578
  }
579
579
  /**
580
580
  * JSON Schema for structured reflect output. Passed to `chatCompletion` when
581
- * the connection has `supportsJsonSchema: true` so the model returns a strict
582
- * JSON object containing only the target-scoped fields AKM cannot derive.
581
+ * {@link wantsJsonSchemaOutput} selects `outputMode: "json_schema"`, so the
582
+ * model returns a strict JSON object containing only the target-scoped
583
+ * fields AKM cannot derive.
583
584
  */
584
585
  const REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA = {
585
586
  type: "object",
@@ -626,6 +627,17 @@ const REFLECT_UNSCOPED_JSON_SCHEMA = {
626
627
  frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
627
628
  },
628
629
  };
630
+ /**
631
+ * Whether to frame the reflect prompt for structured JSON output on this
632
+ * connection. Optimistic by default — `chatCompletion` attempts
633
+ * `response_format: json_schema` fresh on every call and falls back once on
634
+ * a 4xx, so there is no persisted verdict to consult here. `false` only when
635
+ * a human/workflow explicitly disabled it, or a real call already proved
636
+ * this connection rejects it earlier in the same process.
637
+ */
638
+ function wantsJsonSchemaOutput(connection) {
639
+ return connection.supportsJsonSchema !== false && !isJsonSchemaKnownUnsupported(connection);
640
+ }
629
641
  /** Critique prompt injected between prior draft and refinement request (Self-Refine loop). */
630
642
  const REFLECT_CRITIQUE_PROMPT = "Your previous proposal is shown above. Review it critically and provide an improved version that is more specific, actionable, and avoids any issues with the previous attempt. Return only the improved response using the output contract from the original prompt.";
631
643
  /**
@@ -1354,21 +1366,20 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1354
1366
  }
1355
1367
  /**
1356
1368
  * Run the agent with the optional Self-Refine loop (R-1 / #372): up to
1357
- * MAX_REFINE_ITERS invocations, each injecting the prior draft as self-critique
1369
+ * `maxRefineIters` invocations, each injecting the prior draft as self-critique
1358
1370
  * context and exiting early on a no-op refinement. Synthesizes per-iteration
1359
1371
  * draft paths into `draftPathsToCleanup` (mutated) and returns the final agent
1360
1372
  * result + last draft path. Extracted verbatim from `akmReflect`.
1361
1373
  */
1362
1374
  async function runReflectRefineIterations(args) {
1363
1375
  const { options, parsedRef, assetContent, feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices, } = args;
1364
- const MAX_REFINE_ITERS = 3;
1365
- const maxRefineIters = Math.min(Math.max(1, options.maxRefineIters ?? 1), MAX_REFINE_ITERS);
1376
+ const maxRefineIters = Math.max(1, options.maxRefineIters ?? 1);
1366
1377
  // Determine whether this dispatch can honour the file-write contract.
1367
1378
  // Agent CLI + OpenCode SDK runners both have filesystem access; the direct
1368
1379
  // LLM HTTP runner does NOT.
1369
1380
  const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1370
1381
  const outputMode = runnerIsLlm(runnerSpec)
1371
- ? runnerSpec.connection.supportsJsonSchema
1382
+ ? wantsJsonSchemaOutput(runnerSpec.connection)
1372
1383
  ? "json_schema"
1373
1384
  : "framed_markdown"
1374
1385
  : undefined;
@@ -1421,10 +1432,10 @@ async function runReflectRefineIterations(args) {
1421
1432
  ...(options.signal ? { signal: options.signal } : {}),
1422
1433
  priorDraft,
1423
1434
  iteration: iter,
1424
- ...(runnerSpec.connection.supportsJsonSchema
1435
+ ...(outputMode === "json_schema"
1425
1436
  ? { responseSchema: options.ref ? REFLECT_JSON_SCHEMA : REFLECT_UNSCOPED_JSON_SCHEMA }
1426
1437
  : {}),
1427
- outputMode: runnerSpec.connection.supportsJsonSchema ? "json_schema" : "framed_markdown",
1438
+ outputMode: outputMode ?? "framed_markdown",
1428
1439
  ...(options.ref ? { targetRef: options.ref } : {}),
1429
1440
  allowRepair: repairAttempts === 0,
1430
1441
  ...(options.chat ? { chat: options.chat } : {}),
@@ -92,7 +92,7 @@ function collectWorkflowFiles(dir) {
92
92
  continue;
93
93
  const full = path.join(dir, entry.name);
94
94
  if (entry.isDirectory()) {
95
- if (entry.name === ".cache" || entry.name === "registry")
95
+ if (isAkmRegistryCachePath(full))
96
96
  continue;
97
97
  results.push(...collectWorkflowFiles(full));
98
98
  continue;
@@ -185,11 +185,9 @@ function collectAdapterFiles(root, extensions) {
185
185
  walk(full);
186
186
  }
187
187
  else if (entry.isFile() && matchesAdapterExtension(entry.name, extensions)) {
188
- // Compare PATH SEGMENTS, not raw substrings: `path.join` yields `\` on
189
- // Windows so a `"/.cache/"` substring test never matches there, and a
190
- // substring test would also skip a legitimately-named `registry` file.
191
- const segments = path.relative(root, full).split(/[\\/]/);
192
- if (segments.includes(".cache") || segments.includes("registry"))
188
+ // Skip akm's OWN resolved registry-cache copies, not any user
189
+ // directory that happens to be named ".cache" or "registry".
190
+ if (isAkmRegistryCachePath(full))
193
191
  continue;
194
192
  results.push(full);
195
193
  }
@@ -196,13 +196,6 @@ export function hasHotCaptureMode(frontmatter) {
196
196
  * with 80-90% overlap legitimately compress to well under 50% of the larger.
197
197
  */
198
198
  export const MERGE_SHRINK_RATIO_MIN = 0.3;
199
- /**
200
- * Absolute floor (chars) for merged body. When sources are short (<~333 chars),
201
- * `MERGE_SHRINK_RATIO_MIN × largerBodyLen` falls below this and the absolute
202
- * floor dominates — prevents false positives on very terse memory pairs.
203
- * Matches the existing `promote_source_too_small` floor of 100 chars.
204
- */
205
- export const MERGE_ABSOLUTE_FLOOR_CHARS = 100;
206
199
  // ── Reflect size gate ────────────────────────────────────────────────────────
207
200
  /** Ratio lower-bound: proposed body must be at least this fraction of source. */
208
201
  export const REFLECT_SHRINK_RATIO_MIN = 0.5;
@@ -13,20 +13,25 @@
13
13
  * keying rules across call sites.
14
14
  */
15
15
  import path from "node:path";
16
- import { primaryBundlePath } from "../../core/config/config.js";
16
+ import { bundleKeyForContentRoot, primaryBundlePath } from "../../core/config/config.js";
17
17
  import { deriveBundleId } from "../../indexer/installations.js";
18
18
  export { primaryBundlePath };
19
19
  /**
20
20
  * Upsert the primary filesystem bundle (`{ path, writable: true }`) and point
21
21
  * `defaultBundle` at it. Reuses the current default key when it already names a
22
- * filesystem bundle (so re-pointing the primary keeps a stable id); otherwise
23
- * derives a fresh slug-legal key from the path.
22
+ * filesystem bundle (so re-pointing the primary keeps a stable id). Otherwise,
23
+ * reuses whichever configured bundle ALREADY resolves to `stashDir`'s content
24
+ * root (issue #870 — `AKM_BUNDLE_DIR`/`--dir` pointed at a directory already
25
+ * configured under a different id must not mint a second bundle for it); only
26
+ * when no bundle owns that root yet is a fresh slug-legal key derived.
24
27
  */
25
28
  export function withPrimaryBundle(config, stashDir) {
26
29
  const bundles = { ...(config.bundles ?? {}) };
27
30
  let key = config.defaultBundle;
28
31
  if (!key || !(key in bundles) || typeof bundles[key]?.path !== "string") {
29
- key = deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
32
+ key =
33
+ bundleKeyForContentRoot(config, path.resolve(stashDir)) ??
34
+ deriveBundleId(undefined, stashDir, new Set(Object.keys(bundles)));
30
35
  }
31
36
  bundles[key] = {
32
37
  ...bundles[key],
@@ -8,7 +8,6 @@ import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-ac
8
8
  import { getDbPath } from "../../core/paths.js";
9
9
  import { formatRegistryUrl } from "../../core/registry-url.js";
10
10
  import { error } from "../../core/warn.js";
11
- import { getEffectiveSemanticStatus, readSemanticStatus } from "../../indexer/search/semantic-status.js";
12
11
  import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
13
12
  import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
14
13
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
@@ -29,13 +28,6 @@ export function assembleInfo(options) {
29
28
  const defaultBundle = config.defaultBundle ?? null;
30
29
  // Asset types (copy into a mutable array — `placementTypes()` returns readonly)
31
30
  const assetTypes = [...placementTypes()];
32
- const semanticRuntime = readSemanticStatus();
33
- const semanticStatus = getEffectiveSemanticStatus(config, semanticRuntime);
34
- // Search modes
35
- const searchModes = ["fts"];
36
- if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
37
- searchModes.push("semantic", "hybrid");
38
- }
39
31
  // Registries (strip sensitive fields like apiKey from options)
40
32
  const registries = (config.registries ?? []).map((r) => ({
41
33
  url: formatRegistryUrl(r.url),
@@ -58,6 +50,20 @@ export function assembleInfo(options) {
58
50
  // health and search use, so info reads the same database they do.
59
51
  const resolvedDbPath = options?.dbPath ?? getDbPath();
60
52
  const indexStats = readIndexStats(resolvedDbPath);
53
+ // Semantic status is read live from the index's own state, not a cached
54
+ // verdict — a failed embed attempt at search time falls back to FTS and
55
+ // reports that in the search response, it never disables the mode here.
56
+ const semanticStatus = config.semanticSearchMode === "off"
57
+ ? "disabled"
58
+ : !indexStats.hasEmbeddings
59
+ ? "pending"
60
+ : indexStats.vecAvailable
61
+ ? "ready-vec"
62
+ : "ready-js";
63
+ const searchModes = ["fts"];
64
+ if (semanticStatus === "ready-js" || semanticStatus === "ready-vec") {
65
+ searchModes.push("semantic", "hybrid");
66
+ }
61
67
  return {
62
68
  schemaVersion: 1,
63
69
  version: pkgVersion,
@@ -68,8 +74,6 @@ export function assembleInfo(options) {
68
74
  semanticSearch: {
69
75
  mode: config.semanticSearchMode,
70
76
  status: semanticStatus,
71
- ...(semanticRuntime?.reason ? { reason: semanticRuntime.reason } : {}),
72
- ...(semanticRuntime?.message ? { message: semanticRuntime.message } : {}),
73
77
  },
74
78
  registries,
75
79
  sourceProviders,
@@ -39,7 +39,6 @@ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot,
39
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";
@@ -971,13 +970,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
971
970
  if (!rootStat.isDirectory()) {
972
971
  throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
973
972
  }
974
- const common = {
975
- filePath,
976
- rootRealPath,
977
- rootPhysicalIdentity: rootStat.ino === 0n ? `path:${rootRealPath}` : `inode:${rootStat.dev}:${rootStat.ino}`,
978
- rootMtimeNs: String(rootStat.mtimeNs),
979
- rootCtimeNs: String(rootStat.ctimeNs),
980
- };
973
+ const common = { filePath, rootRealPath };
981
974
  let descriptor;
982
975
  try {
983
976
  const noFollow = "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
@@ -986,9 +979,6 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
986
979
  if (!before.isFile()) {
987
980
  throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
988
981
  }
989
- if (before.size > BigInt(TASK_V3_MAX_SOURCE_BYTES)) {
990
- throw new UsageError(`${filePath} exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) task source limit.`, "INVALID_FLAG_VALUE");
991
- }
992
982
  const bytes = fs.readFileSync(descriptor);
993
983
  const after = fs.fstatSync(descriptor, { bigint: true });
994
984
  if (!sameTaskSourceStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
@@ -1010,10 +1000,7 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
1010
1000
  state: "present",
1011
1001
  ...common,
1012
1002
  realPath,
1013
- physicalIdentity: before.ino === 0n ? `path:${realPath}` : `inode:${before.dev}:${before.ino}`,
1014
1003
  size: bytes.byteLength,
1015
- mtimeNs: String(before.mtimeNs),
1016
- ctimeNs: String(before.ctimeNs),
1017
1004
  sha256: createHash("sha256").update(bytes).digest("hex"),
1018
1005
  bytesBase64: bytes.toString("base64"),
1019
1006
  content,
@@ -1035,6 +1022,11 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
1035
1022
  fs.closeSync(descriptor);
1036
1023
  }
1037
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.
1038
1030
  function assertTaskSourceExpectation(expected) {
1039
1031
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1040
1032
  if (!sameTaskSourceExpectation(actual, expected)) {
@@ -1042,12 +1034,16 @@ function assertTaskSourceExpectation(expected) {
1042
1034
  }
1043
1035
  }
1044
1036
  function sameTaskSourceExpectation(left, right) {
1045
- 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;
1046
1043
  }
1047
1044
  function assertTaskSourceRestored(expected) {
1048
1045
  const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1049
1046
  const restored = actual.state === expected.state &&
1050
- actual.rootPhysicalIdentity === expected.rootPhysicalIdentity &&
1051
1047
  (actual.state === "absent" ||
1052
1048
  (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
1053
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;
@@ -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)
@@ -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(),
@@ -16,7 +16,8 @@ const SearchGraphBoostSchema = z
16
16
  hopBoostCap: nonNegativeNumber.optional(),
17
17
  /** Hard-capped at 3; values > 3 hard-error so users see the typo. */
18
18
  maxHops: positiveInt.max(3).optional(),
19
- confidenceMode: z.enum(["off", "blend", "multiply"]).default("blend").optional(),
19
+ /** Only "blend" is exercised; "off"/"multiply" were never set in practice and were removed. */
20
+ confidenceMode: z.enum(["blend"]).default("blend").optional(),
20
21
  /** Range [0, 1]; values > 1 hard-error (no silent clamp). */
21
22
  confidenceWeight: z.number().finite().min(0).max(1).default(0.2).optional(),
22
23
  })
@@ -4,6 +4,7 @@
4
4
  import { randomUUID } from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { sleepSync } from "../runtime.js";
7
8
  import { openDatabase } from "../storage/database.js";
8
9
  import { isProcessAlive, MAX_LOCK_METADATA_BYTES, readTextFileDescriptorWithLimit } from "./common.js";
9
10
  function readLockSnapshot(lockPath) {
@@ -51,7 +52,7 @@ function withLockOperationMutex(lockPath, run) {
51
52
  db.exec("BEGIN IMMEDIATE");
52
53
  began = db.inTransaction;
53
54
  if (!began)
54
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2 ** attempt);
55
+ sleepSync(2 ** attempt);
55
56
  }
56
57
  if (!began)
57
58
  throw new Error(`Could not acquire lock operation mutex for ${lockPath}.`);