akm-cli 0.9.0-rc.0 → 0.9.0-rc.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 (262) hide show
  1. package/CHANGELOG.md +339 -2
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  11. package/dist/assets/stash-skeleton/README.md +28 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  24. package/dist/assets/tasks/core/backup.yml +1 -0
  25. package/dist/assets/tasks/core/extract.yml +1 -0
  26. package/dist/assets/tasks/core/improve.yml +1 -0
  27. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  28. package/dist/assets/tasks/core/sync.yml +1 -0
  29. package/dist/assets/tasks/core/version-check.yml +1 -0
  30. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  31. package/dist/assets/templates/html/health.html +5 -1
  32. package/dist/cli/config-migrate.js +31 -138
  33. package/dist/cli/config-validate.js +10 -8
  34. package/dist/cli.js +48 -14
  35. package/dist/commands/agent/agent-dispatch.js +17 -16
  36. package/dist/commands/agent/agent-support.js +0 -24
  37. package/dist/commands/agent/contribute-cli.js +5 -15
  38. package/dist/commands/backup-cli.js +54 -0
  39. package/dist/commands/config-cli.js +45 -159
  40. package/dist/commands/env/env-binding.js +95 -0
  41. package/dist/commands/env/env-cli.js +8 -65
  42. package/dist/commands/env/secret.js +8 -5
  43. package/dist/commands/health/checks.js +130 -83
  44. package/dist/commands/health/html-report.js +4 -0
  45. package/dist/commands/health/improve-metrics.js +30 -32
  46. package/dist/commands/health/llm-usage.js +19 -19
  47. package/dist/commands/health/md-report.js +4 -0
  48. package/dist/commands/health/metrics.js +2 -1
  49. package/dist/commands/health/surfaces.js +5 -4
  50. package/dist/commands/health.js +1 -1
  51. package/dist/commands/improve/consolidate/chunking.js +2 -2
  52. package/dist/commands/improve/consolidate.js +28 -25
  53. package/dist/commands/improve/distill/promote-memory.js +5 -12
  54. package/dist/commands/improve/distill/quality-gate.js +5 -7
  55. package/dist/commands/improve/distill.js +16 -5
  56. package/dist/commands/improve/eligibility.js +22 -12
  57. package/dist/commands/improve/extract-cli.js +47 -19
  58. package/dist/commands/improve/extract.js +110 -62
  59. package/dist/commands/improve/improve-cli.js +38 -16
  60. package/dist/commands/improve/improve-result-file.js +30 -24
  61. package/dist/commands/improve/improve-strategies.js +137 -0
  62. package/dist/commands/improve/improve.js +60 -30
  63. package/dist/commands/improve/locks.js +66 -45
  64. package/dist/commands/improve/loop-stages.js +75 -33
  65. package/dist/commands/improve/memory/memory-belief.js +79 -7
  66. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  67. package/dist/commands/improve/preparation.js +71 -73
  68. package/dist/commands/improve/procedural.js +3 -2
  69. package/dist/commands/improve/recombine.js +2 -1
  70. package/dist/commands/improve/reflect.js +119 -214
  71. package/dist/commands/improve/shared.js +11 -5
  72. package/dist/commands/lint/base-linter.js +152 -42
  73. package/dist/commands/mv-cli.js +809 -0
  74. package/dist/commands/proposal/proposal-cli.js +18 -8
  75. package/dist/commands/proposal/propose.js +64 -69
  76. package/dist/commands/read/knowledge.js +436 -4
  77. package/dist/commands/read/remember-cli.js +39 -2
  78. package/dist/commands/read/search-cli.js +6 -1
  79. package/dist/commands/registry-cli.js +29 -14
  80. package/dist/commands/remember.js +2 -0
  81. package/dist/commands/sources/init.js +13 -14
  82. package/dist/commands/sources/migration-help.js +7 -4
  83. package/dist/commands/sources/schema-repair.js +2 -4
  84. package/dist/commands/sources/source-add.js +62 -73
  85. package/dist/commands/sources/source-manage.js +50 -46
  86. package/dist/commands/sources/stash-cli.js +41 -4
  87. package/dist/commands/tasks/default-tasks.js +12 -12
  88. package/dist/commands/tasks/tasks-cli.js +7 -3
  89. package/dist/commands/tasks/tasks.js +113 -18
  90. package/dist/commands/wiki-cli.js +9 -10
  91. package/dist/commands/workflow-cli.js +276 -12
  92. package/dist/core/asset/asset-spec.js +58 -1
  93. package/dist/core/asset/frontmatter.js +12 -2
  94. package/dist/core/common.js +5 -3
  95. package/dist/core/config/config-io.js +28 -17
  96. package/dist/core/config/config-schema.js +379 -66
  97. package/dist/core/config/config-types.js +3 -3
  98. package/dist/core/config/config-version.js +29 -0
  99. package/dist/core/config/config-walker.js +98 -27
  100. package/dist/core/config/config.js +132 -266
  101. package/dist/core/config/deep-merge.js +41 -0
  102. package/dist/core/config/engine-semantics.js +32 -0
  103. package/dist/core/errors.js +2 -2
  104. package/dist/core/extra-params.js +61 -0
  105. package/dist/core/file-lock.js +201 -56
  106. package/dist/core/improve-result.js +178 -0
  107. package/dist/core/json-schema.js +142 -0
  108. package/dist/core/maintenance-barrier.js +119 -0
  109. package/dist/core/migration-backup.js +416 -0
  110. package/dist/core/paths.js +3 -0
  111. package/dist/core/redaction.js +358 -0
  112. package/dist/core/state/migrations.js +17 -2
  113. package/dist/core/state-db.js +44 -1
  114. package/dist/indexer/db/db.js +118 -2
  115. package/dist/indexer/graph/graph-extraction.js +28 -16
  116. package/dist/indexer/index-writer-lock.js +31 -24
  117. package/dist/indexer/index-written-assets.js +15 -6
  118. package/dist/indexer/indexer.js +47 -2
  119. package/dist/indexer/passes/memory-inference.js +10 -6
  120. package/dist/indexer/passes/metadata.js +250 -0
  121. package/dist/indexer/search/db-search.js +111 -44
  122. package/dist/indexer/search/fts-query.js +41 -0
  123. package/dist/indexer/search/ranking-contributors.js +48 -0
  124. package/dist/indexer/search/ranking.js +36 -23
  125. package/dist/indexer/search/search-fields.js +11 -1
  126. package/dist/indexer/walk/matchers.js +39 -0
  127. package/dist/integrations/agent/builder-shared.js +7 -0
  128. package/dist/integrations/agent/builders.js +5 -50
  129. package/dist/integrations/agent/config.js +3 -143
  130. package/dist/integrations/agent/detect.js +17 -2
  131. package/dist/integrations/agent/engine-resolution.js +202 -0
  132. package/dist/integrations/agent/index.js +1 -2
  133. package/dist/integrations/agent/model-aliases.js +16 -2
  134. package/dist/integrations/agent/profiles.js +36 -62
  135. package/dist/integrations/agent/runner-dispatch.js +91 -4
  136. package/dist/integrations/agent/runner.js +76 -207
  137. package/dist/integrations/agent/spawn.js +141 -20
  138. package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
  139. package/dist/integrations/harnesses/aider/index.js +57 -0
  140. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  141. package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
  142. package/dist/integrations/harnesses/amazonq/index.js +58 -0
  143. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  144. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  145. package/dist/integrations/harnesses/claude/index.js +25 -25
  146. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  147. package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
  148. package/dist/integrations/harnesses/codex/index.js +62 -0
  149. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  150. package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
  151. package/dist/integrations/harnesses/copilot/index.js +59 -0
  152. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  153. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  154. package/dist/integrations/harnesses/gemini/index.js +59 -0
  155. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  156. package/dist/integrations/harnesses/index.js +27 -28
  157. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  158. package/dist/integrations/harnesses/opencode/index.js +15 -22
  159. package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
  160. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  161. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  162. package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
  163. package/dist/integrations/harnesses/openhands/index.js +57 -0
  164. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  165. package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
  166. package/dist/integrations/harnesses/pi/index.js +57 -0
  167. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  168. package/dist/integrations/harnesses/types.js +8 -32
  169. package/dist/integrations/lockfile.js +32 -21
  170. package/dist/integrations/session-logs/index.js +24 -11
  171. package/dist/llm/client.js +48 -14
  172. package/dist/llm/feature-gate.js +15 -47
  173. package/dist/llm/graph-extract.js +1 -1
  174. package/dist/llm/index-passes.js +8 -42
  175. package/dist/llm/memory-infer-impl.js +1 -1
  176. package/dist/llm/usage-persist.js +4 -0
  177. package/dist/llm/usage-telemetry.js +35 -5
  178. package/dist/output/renderers.js +3 -2
  179. package/dist/output/shapes/helpers.js +2 -1
  180. package/dist/output/shapes/passthrough.js +6 -0
  181. package/dist/output/text/helpers.js +215 -2
  182. package/dist/output/text/workflow.js +3 -1
  183. package/dist/schemas/akm-config.json +16638 -0
  184. package/dist/schemas/akm-task.json +87 -0
  185. package/dist/schemas/akm-workflow.json +372 -0
  186. package/dist/scripts/migrate-storage.js +10944 -8801
  187. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
  188. package/dist/setup/detected-engines.js +142 -0
  189. package/dist/setup/engine-config.js +89 -0
  190. package/dist/setup/setup.js +236 -132
  191. package/dist/setup/steps/connection.js +61 -32
  192. package/dist/setup/steps/platforms.js +4 -4
  193. package/dist/setup/steps.js +3 -2
  194. package/dist/storage/database.js +13 -1
  195. package/dist/storage/engines/sqlite-migrations.js +1 -0
  196. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  197. package/dist/storage/repositories/task-history-repository.js +78 -0
  198. package/dist/storage/repositories/workflow-runs-repository.js +190 -1
  199. package/dist/tasks/parser.js +138 -52
  200. package/dist/tasks/runner.js +71 -75
  201. package/dist/tasks/schema.js +1 -1
  202. package/dist/tasks/validator.js +11 -6
  203. package/dist/text-import-hook.mjs +1 -1
  204. package/dist/wiki/wiki.js +9 -8
  205. package/dist/workflows/authoring/authoring.js +123 -10
  206. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  207. package/dist/workflows/cli.js +4 -0
  208. package/dist/workflows/concurrency-policy.js +15 -0
  209. package/dist/workflows/db.js +200 -13
  210. package/dist/workflows/exec/brief.js +478 -0
  211. package/dist/workflows/exec/frozen-judge.js +47 -0
  212. package/dist/workflows/exec/native-executor.js +1034 -0
  213. package/dist/workflows/exec/param-secrets.js +115 -0
  214. package/dist/workflows/exec/report.js +1355 -0
  215. package/dist/workflows/exec/run-workflow.js +609 -0
  216. package/dist/workflows/exec/scheduler.js +71 -0
  217. package/dist/workflows/exec/step-work.js +1212 -0
  218. package/dist/workflows/exec/unit-writer.js +23 -0
  219. package/dist/workflows/exec/watch.js +116 -0
  220. package/dist/workflows/exec/worktree.js +171 -0
  221. package/dist/workflows/ir/compile.js +375 -0
  222. package/dist/workflows/ir/freeze.js +243 -0
  223. package/dist/workflows/ir/params.js +54 -0
  224. package/dist/workflows/ir/plan-hash.js +68 -0
  225. package/dist/workflows/ir/schema.js +545 -0
  226. package/dist/workflows/parser.js +10 -1
  227. package/dist/workflows/program/expressions.js +369 -0
  228. package/dist/workflows/program/parser.js +869 -0
  229. package/dist/workflows/program/project.js +104 -0
  230. package/dist/workflows/program/schema.js +54 -0
  231. package/dist/workflows/renderer.js +82 -5
  232. package/dist/workflows/resource-limits.js +20 -0
  233. package/dist/workflows/runtime/agent-identity.js +59 -14
  234. package/dist/workflows/runtime/plan-classifier.js +187 -0
  235. package/dist/workflows/runtime/runs.js +246 -69
  236. package/dist/workflows/runtime/unit-checkin.js +45 -0
  237. package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
  238. package/dist/workflows/validate-summary.js +24 -3
  239. package/dist/workflows/validator.js +26 -1
  240. package/docs/data-and-telemetry.md +4 -3
  241. package/docs/migration/release-notes/0.6.0.md +1 -1
  242. package/docs/migration/release-notes/0.7.0.md +5 -4
  243. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  244. package/docs/migration/v0.8-to-v0.9.md +401 -0
  245. package/package.json +4 -2
  246. package/schemas/akm-config.json +16638 -0
  247. package/schemas/akm-task.json +87 -0
  248. package/schemas/akm-workflow.json +372 -0
  249. package/dist/commands/improve/improve-profiles.js +0 -168
  250. package/dist/core/config/config-migration.js +0 -602
  251. package/dist/core/deep-merge.js +0 -38
  252. package/dist/llm/call-ai.js +0 -62
  253. package/dist/setup/legacy-config.js +0 -106
  254. package/docs/README.md +0 -104
  255. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  256. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  257. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  258. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  259. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  260. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  261. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  262. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -1,6 +1,7 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import { buildWorkflowAction } from "../../output/renderers.js";
6
7
  import { registerActionBuilder, registerTypeRenderer } from "./asset-registry.js";
@@ -8,6 +9,59 @@ function toPosix(input) {
8
9
  return input.replace(/\\/g, "/");
9
10
  }
10
11
  const buildTaskAction = (ref) => `akm tasks show ${ref.replace(/^task:/, "")} -> inspect; akm tasks run <id> -> run now; akm tasks remove <id> -> unschedule`;
12
+ /**
13
+ * Recognized workflow asset extensions, in resolution-priority order.
14
+ * `.md` (classic linear markdown workflows — the stable contract) stays
15
+ * FIRST for back-compat; `.yaml`/`.yml` hold YAML workflow *programs*
16
+ * (redesign addendum, R1). `workflow:<name>` refs resolve against this list.
17
+ */
18
+ export const WORKFLOW_EXTENSIONS = [".md", ".yaml", ".yml"];
19
+ /**
20
+ * Strip a recognized workflow extension (`.md`/`.yaml`/`.yml`) from a workflow
21
+ * asset *name* so `foo`, `foo.yaml`, `foo.yml`, and `foo.md` collapse to one
22
+ * canonical identity — the same collapse `workflowSpec.toCanonicalName`
23
+ * performs on a resolved file path. Callers that turn a `workflow:<name>` ref
24
+ * into run identity (the active-run guard, list/status filters) MUST route the
25
+ * name through this so an aliased spelling (`workflow:foo.yaml`) and the
26
+ * canonical `workflow:foo` cannot start or hide parallel runs of the same
27
+ * workflow. Names without a recognized workflow extension pass through
28
+ * unchanged.
29
+ */
30
+ export function canonicalizeWorkflowName(name) {
31
+ const lower = name.toLowerCase();
32
+ for (const ext of WORKFLOW_EXTENSIONS) {
33
+ if (lower.endsWith(ext))
34
+ return name.slice(0, -ext.length);
35
+ }
36
+ return name;
37
+ }
38
+ const workflowSpec = {
39
+ isRelevantFile: (fileName) => WORKFLOW_EXTENSIONS.includes(path.extname(fileName).toLowerCase()),
40
+ toCanonicalName: (typeRoot, filePath) => {
41
+ const rel = toPosix(path.relative(typeRoot, filePath));
42
+ for (const ext of WORKFLOW_EXTENSIONS) {
43
+ if (rel.toLowerCase().endsWith(ext))
44
+ return rel.slice(0, -ext.length);
45
+ }
46
+ return rel;
47
+ },
48
+ toAssetPath: (typeRoot, name) => {
49
+ // Explicit extension wins (accepts refs like "release/ship.yaml").
50
+ const lower = name.toLowerCase();
51
+ for (const ext of WORKFLOW_EXTENSIONS) {
52
+ if (lower.endsWith(ext))
53
+ return path.join(typeRoot, name);
54
+ }
55
+ // Probe in priority order — `.md` first for back-compat — and fall back
56
+ // to the markdown path so error messages keep naming the canonical file.
57
+ for (const ext of WORKFLOW_EXTENSIONS) {
58
+ const candidate = path.join(typeRoot, `${name}${ext}`);
59
+ if (fs.existsSync(candidate))
60
+ return candidate;
61
+ }
62
+ return path.join(typeRoot, `${name}.md`);
63
+ },
64
+ };
11
65
  const markdownSpec = {
12
66
  isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".md",
13
67
  toCanonicalName: (typeRoot, filePath) => {
@@ -62,7 +116,10 @@ const ASSET_SPECS_INTERNAL = {
62
116
  knowledge: { stashDir: "knowledge", ...markdownSpec },
63
117
  workflow: {
64
118
  stashDir: "workflows",
65
- ...markdownSpec,
119
+ ...workflowSpec,
120
+ // Type-level renderer for markdown workflows; YAML programs are claimed by
121
+ // the dedicated `workflowProgramMatcher`, which names the
122
+ // "workflow-program-yaml" renderer directly on its MatchResult.
66
123
  rendererName: "workflow-md",
67
124
  actionBuilder: (ref) => buildWorkflowAction(ref),
68
125
  },
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import fs from "node:fs";
12
12
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
13
- import { assembleAsset } from "./asset-serialize.js";
13
+ import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
14
14
  /**
15
15
  * Parse YAML frontmatter from a Markdown (or similar) string.
16
16
  *
@@ -118,6 +118,13 @@ function parseFrontmatterLenient(frontmatter) {
118
118
  * or `null` to skip the write entirely (e.g. for idempotent no-ops). The body
119
119
  * content is preserved from the parse.
120
120
  *
121
+ * A frontmatter mutation is a METADATA edit, not a content edit: when the file
122
+ * already has a frontmatter block, only that block is replaced and the body
123
+ * bytes are kept verbatim (routing through `assembleAsset` would strip the
124
+ * body's leading blank lines and force a trailing newline, silently reshaping
125
+ * assets whose writer used a different separator style). A file gaining its
126
+ * FIRST frontmatter block goes through the canonical `assembleAsset` shape.
127
+ *
121
128
  * @returns `true` if a write occurred, `false` if the mutator returned `null`.
122
129
  */
123
130
  export function mutateFrontmatter(filePath, mutator) {
@@ -126,7 +133,10 @@ export function mutateFrontmatter(filePath, mutator) {
126
133
  const nextFrontmatter = mutator(parsed);
127
134
  if (nextFrontmatter === null)
128
135
  return false;
129
- fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
136
+ const next = parsed.frontmatter !== null
137
+ ? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
138
+ : assembleAsset(nextFrontmatter, parsed.content);
139
+ fs.writeFileSync(filePath, next, "utf8");
130
140
  return true;
131
141
  }
132
142
  export function parseFrontmatterBlock(raw) {
@@ -243,11 +243,12 @@ function normalizeFsPathForComparison(value) {
243
243
  */
244
244
  export async function fetchWithTimeout(url, opts, timeoutMs = 30_000, signal) {
245
245
  const controller = new AbortController();
246
- const timer = setTimeout(() => controller.abort(), timeoutMs);
246
+ const timer = timeoutMs === null ? undefined : setTimeout(() => controller.abort(), timeoutMs);
247
247
  const abortExternal = () => controller.abort(signal?.reason);
248
248
  if (signal) {
249
249
  if (signal.aborted) {
250
- clearTimeout(timer);
250
+ if (timer)
251
+ clearTimeout(timer);
251
252
  controller.abort(signal.reason);
252
253
  }
253
254
  else {
@@ -269,7 +270,8 @@ export async function fetchWithTimeout(url, opts, timeoutMs = 30_000, signal) {
269
270
  finally {
270
271
  if (signal)
271
272
  signal.removeEventListener("abort", abortExternal);
272
- clearTimeout(timer);
273
+ if (timer)
274
+ clearTimeout(timer);
273
275
  }
274
276
  }
275
277
  /**
@@ -17,7 +17,7 @@ import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
18
  import { writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
- import { probeLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
20
+ import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
22
22
  /**
23
23
  * Read the raw text of a config file. Returns `undefined` when the file does
@@ -80,7 +80,7 @@ export function writeConfigAtomic(configPath, config) {
80
80
  }
81
81
  /** Maximum number of timestamped config backups to retain (#459). */
82
82
  const MAX_CONFIG_BACKUPS = 5;
83
- export function backupExistingConfig(configPath) {
83
+ export function backupExistingConfig(configPath, now = new Date()) {
84
84
  if (!fs.existsSync(configPath))
85
85
  return undefined;
86
86
  const backupDir = path.join(getCacheDir(), "config-backups");
@@ -89,10 +89,22 @@ export function backupExistingConfig(configPath) {
89
89
  // the copy→chmod window. chmod again to tighten a dir from an older version.
90
90
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
91
91
  fs.chmodSync(backupDir, 0o700);
92
- const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
93
- const timestamped = path.join(backupDir, `config-${timestamp}.json`);
92
+ const timestamp = now.toISOString().replace(/[.:]/g, "-");
93
+ let sequence = 0;
94
+ let timestamped;
95
+ while (true) {
96
+ timestamped = path.join(backupDir, `config-${timestamp}${sequence === 0 ? "" : `-${sequence}`}.json`);
97
+ try {
98
+ fs.copyFileSync(configPath, timestamped, fs.constants.COPYFILE_EXCL);
99
+ break;
100
+ }
101
+ catch (error) {
102
+ if (error.code !== "EEXIST")
103
+ throw error;
104
+ sequence++;
105
+ }
106
+ }
94
107
  const latest = path.join(backupDir, "config.latest.json");
95
- fs.copyFileSync(configPath, timestamped);
96
108
  fs.copyFileSync(configPath, latest);
97
109
  // 08-F4: a config backup carries the same sensitive fields as the live config
98
110
  // (endpoints, tokens). `copyFileSync` inherits the source's (often 0644) mode,
@@ -141,7 +153,7 @@ function pruneOldBackups(backupDir) {
141
153
  * predictable for debugging. Uses $CONFIG (not $DATA) because config.json
142
154
  * itself lives in $CONFIG — they should fail together if the dir is read-only.
143
155
  */
144
- function getConfigLockPath() {
156
+ export function getConfigLockPath() {
145
157
  return path.join(getConfigDir(), "config.json.lck");
146
158
  }
147
159
  const CONFIG_LOCK_MAX_RETRIES = 10;
@@ -157,8 +169,8 @@ function sleepSyncMs(ms) {
157
169
  /**
158
170
  * Acquire an exclusive sentinel around config writes.
159
171
  *
160
- * Returns a release function. Best-effort: when all retries are exhausted the
161
- * write proceeds unlocked rather than erroring (same posture as lockfile.ts).
172
+ * Returns a release function. Acquisition is fail-closed: config mutation may
173
+ * never continue without owning the lock that protects its read/merge/write.
162
174
  */
163
175
  export function acquireConfigLock() {
164
176
  const lockPath = getConfigLockPath();
@@ -170,16 +182,16 @@ export function acquireConfigLock() {
170
182
  }
171
183
  for (let attempt = 0; attempt < CONFIG_LOCK_MAX_RETRIES; attempt++) {
172
184
  try {
173
- if (tryAcquireLockSync(lockPath, String(process.pid))) {
174
- return () => releaseLock(lockPath);
185
+ const ownership = tryAcquireLockSync(lockPath, createLockPayload());
186
+ if (ownership) {
187
+ return () => releaseLock(ownership);
175
188
  }
176
189
  }
177
- catch {
178
- // Non-EEXIST error (permissions, etc.) bail out and proceed unlocked.
179
- break;
190
+ catch (error) {
191
+ throw new ConfigError(`Unable to acquire config lock at ${lockPath}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
180
192
  }
181
- if (probeLock(lockPath).state === "stale") {
182
- releaseLock(lockPath);
193
+ const probe = probeLock(lockPath);
194
+ if (probe.state === "stale" && reclaimStaleLock(lockPath, probe)) {
183
195
  continue; // Reclaimed — retry immediately.
184
196
  }
185
197
  if (attempt < CONFIG_LOCK_MAX_RETRIES - 1) {
@@ -195,8 +207,7 @@ export function acquireConfigLock() {
195
207
  sleepSyncMs(CONFIG_LOCK_RETRY_DELAY_MS);
196
208
  }
197
209
  }
198
- // Best-effort: proceed without lock.
199
- return () => { };
210
+ throw new ConfigError(`Timed out waiting for config lock at ${lockPath}. Another AKM process may be updating config.`, "INVALID_CONFIG_FILE");
200
211
  }
201
212
  /**
202
213
  * Run `fn` inside the config write lock. Always releases the lock.