akm-cli 0.9.1-beta.1 → 0.9.1-beta.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 (57) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/cli/parse-args.js +7 -1
  3. package/dist/commands/env/child-env.js +14 -0
  4. package/dist/commands/improve/eval-cases.js +2 -0
  5. package/dist/commands/improve/memory/memory-improve.js +1 -0
  6. package/dist/commands/lint/index.js +5 -1
  7. package/dist/commands/sources/add-cli.js +8 -2
  8. package/dist/commands/sources/migration-help.js +12 -3
  9. package/dist/commands/sources/self-update.js +9 -1
  10. package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
  11. package/dist/core/adapter/adapters/akm-lint.js +6 -2
  12. package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
  13. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  14. package/dist/core/asset/frontmatter.js +6 -1
  15. package/dist/core/common.js +81 -3
  16. package/dist/core/config/config-io.js +5 -45
  17. package/dist/core/config/schema/engines.js +14 -3
  18. package/dist/core/extra-params.js +11 -0
  19. package/dist/core/fs-txn.js +15 -2
  20. package/dist/core/json-schema.js +19 -2
  21. package/dist/core/paths.js +16 -2
  22. package/dist/core/redaction.js +22 -1
  23. package/dist/core/state-db.js +1 -0
  24. package/dist/core/write-source.js +26 -2
  25. package/dist/indexer/indexer.js +31 -6
  26. package/dist/indexer/search/db-search.js +17 -2
  27. package/dist/indexer/walk/walker.js +6 -1
  28. package/dist/integrations/agent/detect.js +13 -1
  29. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  30. package/dist/integrations/lockfile.js +10 -0
  31. package/dist/llm/client.js +14 -19
  32. package/dist/llm/embedder.js +23 -3
  33. package/dist/llm/embedders/remote.js +27 -2
  34. package/dist/output/html-render.js +40 -1
  35. package/dist/runtime.js +23 -1
  36. package/dist/scripts/akm-migrate-node.js +303 -107
  37. package/dist/scripts/akm-migrate.js +303 -107
  38. package/dist/setup/setup.js +22 -7
  39. package/dist/sources/providers/git-install.js +25 -2
  40. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  41. package/dist/storage/database.js +71 -12
  42. package/dist/storage/engines/sqlite-migrations.js +61 -2
  43. package/dist/storage/repositories/index-connection.js +11 -1
  44. package/dist/storage/repositories/index-meta-repository.js +11 -0
  45. package/dist/storage/repositories/index-schema.js +17 -2
  46. package/dist/storage/repositories/index-vec-repository.js +43 -5
  47. package/dist/storage/sqlite-pragmas.js +12 -1
  48. package/dist/tasks/runner.js +84 -7
  49. package/dist/tasks/scheduler-invocation.js +19 -0
  50. package/dist/tasks/schema.js +21 -1
  51. package/dist/text-import-hook.mjs +1 -1
  52. package/dist/workflows/exec/native-executor.js +8 -0
  53. package/dist/workflows/exec/step-work.js +10 -2
  54. package/dist/workflows/parser.js +26 -1
  55. package/package.json +1 -1
  56. package/schemas/akm-config.json +10 -5
  57. package/schemas/akm-workflow.json +7 -3
package/CHANGELOG.md CHANGED
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.9.1-beta.1] - 2026-08-13
9
+ ## [0.9.1-beta.2] - 2026-08-17
10
10
 
11
11
  ### Breaking changes & migration
12
12
 
@@ -361,6 +361,23 @@ what an upgrader reads first.
361
361
 
362
362
  ### Fixed
363
363
 
364
+ - **Upgrading Node after installing akm now explains itself.** A native binding
365
+ is built for the Node ABI present at install time, so upgrading Node major
366
+ versions afterwards leaves akm reporting a bare Node internals message —
367
+ *"The module … was compiled against a different Node.js version"*, or on a
368
+ second attempt the even less helpful *"Module did not self-register"*. akm now
369
+ recognises that failure and answers with the one command that fixes it
370
+ (`npm rebuild better-sqlite3`), names the ABI actually running, and says
371
+ plainly that this is not a broken install.
372
+
373
+ The diagnostic had to move to do this. It wrapped the `require`, but
374
+ `require("better-sqlite3")` **succeeds** against a mismatched binding — the
375
+ package resolves its `.node` file lazily — so the error lands at
376
+ `new Database(...)` and the loader's handler never saw it. The previous text
377
+ telling the user to look for a version mismatch "in the error below" was
378
+ unreachable. Found by installing the published build under Node 22 and running
379
+ it under Node 24.
380
+
364
381
  - **akm's Node fallback no longer aborts at teardown on Node 24.** On Node
365
382
  24.19.0 and later, any command that opened a database could intermittently
366
383
  die with `node::RemoveEnvironmentCleanupHook … Assertion (env) != nullptr`
@@ -45,8 +45,14 @@ export function parsePositiveIntFlag(raw, flagName = "--limit") {
45
45
  const trimmed = raw.trim();
46
46
  if (!trimmed)
47
47
  return undefined;
48
+ // Strict digits, matching parseNonNegativeIntFlag below. parseInt stops at the
49
+ // first non-digit, so "10x" silently became 10, "3.5" became 3, and
50
+ // "5 apples" became 5 — accepted rather than rejected as invalid.
51
+ if (!/^\d+$/.test(trimmed)) {
52
+ throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
53
+ }
48
54
  const parsed = parseInt(trimmed, 10);
49
- if (Number.isNaN(parsed) || parsed <= 0) {
55
+ if (parsed <= 0) {
50
56
  throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
51
57
  }
52
58
  return parsed;
@@ -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 { WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
4
5
  const CLEAN_ENV_ALLOWLIST = [
5
6
  "HOME",
6
7
  "PATH",
@@ -38,6 +39,19 @@ export function buildChildEnv(parentEnv, options) {
38
39
  if (parentEnv[key] !== undefined)
39
40
  base[key] = parentEnv[key];
40
41
  }
42
+ // The allowlist above is POSIX-shaped. On Windows a child started without
43
+ // SystemRoot/COMSPEC/PATHEXT and friends frequently cannot start at all —
44
+ // which is why every other spawn path in the codebase applies this floor
45
+ // (see spawnEnvNamesFor). `env run --clean` / `secret run --clean` did not,
46
+ // so clean-mode injection was unusable there. The floor is names the OS
47
+ // requires of any child, not user configuration, so it does not weaken what
48
+ // "clean" means about inherited secrets.
49
+ if (process.platform === "win32") {
50
+ for (const key of WIN32_SPAWN_ENV_FLOOR) {
51
+ if (parentEnv[key] !== undefined)
52
+ base[key] = parentEnv[key];
53
+ }
54
+ }
41
55
  }
42
56
  for (const key of options.inherit) {
43
57
  if (parentEnv[key] !== undefined)
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { writeFileAtomic } from "../../core/common.js";
7
+ import { recordWrittenPath } from "../../core/write-provenance.js";
7
8
  export function writeEvalCase(stashDir, evalCase) {
8
9
  const evalDir = path.join(stashDir, ".akm", "eval-cases");
9
10
  fs.mkdirSync(evalDir, { recursive: true });
@@ -28,6 +29,7 @@ Use it as a regression test: future improve runs on this ref should not produce
28
29
  output that would be rejected for the same reason.
29
30
  `;
30
31
  writeFileAtomic(filePath, content);
32
+ recordWrittenPath(filePath);
31
33
  return filePath;
32
34
  }
33
35
  export function countEvalCases(stashDir) {
@@ -543,6 +543,7 @@ function appendBeliefStateTransitionLog(stashDir, transitions) {
543
543
  }))
544
544
  .join("\n");
545
545
  fs.appendFileSync(logPath, `${lines}\n`, "utf8");
546
+ recordWrittenPath(logPath);
546
547
  return logPath;
547
548
  }
548
549
  function priorBeliefStateForArchive(candidate) {
@@ -502,7 +502,11 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
502
502
  }
503
503
  for (const filePath of assetFiles) {
504
504
  // Skip registry-cached read-only files — --fix must not mutate them.
505
- if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
505
+ // Compare on a separator-normalized copy: on Windows these paths carry
506
+ // backslashes, so the forward-slash substring never matched and --fix
507
+ // rewrote files inside the registry cache.
508
+ const posixPath = filePath.replace(/\\/g, "/");
509
+ if (posixPath.includes("/.cache/") || posixPath.includes("/registry/"))
506
510
  continue;
507
511
  const relPath = path.relative(stashRoot, filePath);
508
512
  let raw;
@@ -10,6 +10,7 @@ import { decideDangerousKeyInstall } from "../../core/activation-policy.js";
10
10
  import { UsageError } from "../../core/errors.js";
11
11
  import { appendEvent } from "../../core/events.js";
12
12
  import { warn } from "../../core/warn.js";
13
+ import { sanitizeString } from "../../sources/providers/provider-utils.js";
13
14
  import { akmRemove } from "./installed-stashes.js";
14
15
  import { akmAdd } from "./source-add.js";
15
16
  import { addStash } from "./source-manage.js";
@@ -180,9 +181,14 @@ export async function auditInstalledStashForDangerousKeys(opts) {
180
181
  groupedByEnv.set(f.envRef, existing);
181
182
  }
182
183
  for (const [envRef, keys] of groupedByEnv) {
183
- warn(`[warn] Env "${envRef}" in stash "${stashLabel}" contains potentially dangerous keys:`);
184
+ // envRef and keys come from filenames and KEY names inside a downloaded or
185
+ // cloned bundle, i.e. attacker-controllable. Tar validation rejects NUL but
186
+ // not ESC/CSI, and git checkout allows them in filenames on Linux/macOS —
187
+ // so printing them raw let a crafted bundle rewrite this security prompt
188
+ // with terminal escapes right before an "Install anyway?" confirmation.
189
+ warn(`[warn] Env "${sanitizeString(envRef)}" in stash "${sanitizeString(stashLabel)}" contains potentially dangerous keys:`);
184
190
  for (const key of keys) {
185
- warn(` - ${key}: can hijack process execution via \`akm env run\``);
191
+ warn(` - ${sanitizeString(key)}: can hijack process execution via \`akm env run\``);
186
192
  }
187
193
  }
188
194
  const confirmed = await p.confirm({
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
6
7
  import { getDirname } from "../../runtime.js";
7
8
  const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
8
9
  const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
@@ -24,9 +25,13 @@ function loadChangelog() {
24
25
  }
25
26
  }
26
27
  catch {
27
- // fall through to bundled notes
28
+ // fall through to the embedded copy
28
29
  }
29
- return undefined;
30
+ // In the `bun build --compile` standalone binary, import.meta.url points into
31
+ // the virtual /$bunfs tree and every existsSync above misses, so `akm help
32
+ // migrate <version>` degraded to the generic "no dedicated note" message for
33
+ // EVERY version. Only assets imported `with { type: "text" }` are embedded.
34
+ return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
30
35
  }
31
36
  /**
32
37
  * Load the bundled migration note for a specific version, if one exists.
@@ -87,7 +92,11 @@ function resolveLatestVersion(changelog) {
87
92
  return undefined;
88
93
  }
89
94
  function extractChangelogSection(changelog, version) {
90
- const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|\\Z)`, "m");
95
+ // `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
96
+ // the section at the first capital Z in the body (and failed outright for the
97
+ // last entry). `$` with the `m` flag would stop at the first line end, so the
98
+ // end-of-input alternative has to be an explicit lookahead for the input end.
99
+ const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
91
100
  const match = changelog.match(pattern);
92
101
  if (!match)
93
102
  return undefined;
@@ -10,6 +10,7 @@ import { ConfigError } from "../../core/errors.js";
10
10
  import { warn } from "../../core/warn.js";
11
11
  import { githubHeaders } from "../../integrations/github.js";
12
12
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
13
+ import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
13
14
  const REPO = "itlackey/akm";
14
15
  const DEFAULT_PACKAGE_NAME = "akm-cli";
15
16
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -474,7 +475,14 @@ function readInstalledCliVersion(akmBin) {
474
475
  return match?.[0];
475
476
  }
476
477
  function runRequiredCommand(akmBin, args, label) {
477
- const result = childProcess.spawnSync(akmBin, args, {
478
+ // A bare "akm" is not spawnable on Windows: npm/pnpm/yarn install a global CLI
479
+ // as akm.cmd / akm.ps1 shims, and spawnSync without a shell does not apply
480
+ // PATHEXT — so the package-manager upgrade arm died with ENOENT before it ever
481
+ // ran. resolveAkmInvocation returns a concrete argv (launcher, runtime + main
482
+ // script, or a standalone binary) for however this install actually runs.
483
+ // An explicit path (the standalone arm passes one) is used as given.
484
+ const [command, ...prefixArgs] = path.isAbsolute(akmBin) ? [akmBin] : resolveAkmInvocation().argv;
485
+ const result = childProcess.spawnSync(command ?? akmBin, [...prefixArgs, ...args], {
478
486
  encoding: "utf8",
479
487
  env: process.env,
480
488
  stdio: "pipe",
@@ -138,13 +138,25 @@ function skillFieldDiagnostics(relPath, dirName, data) {
138
138
  * sweep into a full recursive walk.
139
139
  */
140
140
  const MAX_PACKAGE_PROBE_DEPTH = 3;
141
- /** True when `SKILL.md` exists at `dir` or anywhere within {@link MAX_PACKAGE_PROBE_DEPTH} below it. */
142
- async function subtreeHasManifest(dir, entries, ctx, depth) {
141
+ function missingManifestDiagnostic(dir) {
142
+ return { file: dir, issue: "missing-skill-md", detail: `no SKILL.md in ${dir}/`, fixed: false };
143
+ }
144
+ /**
145
+ * Classify one directory as a package, a grouping directory, or one broken
146
+ * package candidate. Once a real package root is found, its resource
147
+ * directories are never descended into. If manifests exist only below this
148
+ * directory, it is a group and each sibling candidate is checked independently.
149
+ */
150
+ async function scanPackageCandidate(dir, entries, ctx, depth) {
143
151
  if (entries.includes(SKILL_MANIFEST))
144
- return true;
145
- if (depth >= MAX_PACKAGE_PROBE_DEPTH)
146
- return false;
152
+ return { containsManifest: true, diagnostics: [] };
153
+ if (depth >= MAX_PACKAGE_PROBE_DEPTH) {
154
+ return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
155
+ }
156
+ const children = [];
147
157
  for (const entry of entries) {
158
+ if (entry.startsWith("."))
159
+ continue;
148
160
  const child = `${dir}/${entry}`;
149
161
  // `list` on a FILE yields `[]` (the read throws and is swallowed), so an
150
162
  // empty listing is the "not a directory worth descending" signal — no
@@ -152,10 +164,17 @@ async function subtreeHasManifest(dir, entries, ctx, depth) {
152
164
  const childEntries = await ctx.list(child);
153
165
  if (childEntries.length === 0)
154
166
  continue;
155
- if (await subtreeHasManifest(child, childEntries, ctx, depth + 1))
156
- return true;
167
+ children.push(await scanPackageCandidate(child, childEntries, ctx, depth + 1));
168
+ }
169
+ if (children.some((child) => child.containsManifest)) {
170
+ return {
171
+ containsManifest: true,
172
+ diagnostics: children.flatMap((child) => child.diagnostics),
173
+ };
157
174
  }
158
- return false;
175
+ // No descendant establishes this as a grouping directory. Diagnose the
176
+ // candidate itself, not its resource subdirectories.
177
+ return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
159
178
  }
160
179
  /**
161
180
  * The directory-level `missing-skill-md` check (issue #774).
@@ -167,11 +186,10 @@ async function subtreeHasManifest(dir, entries, ctx, depth) {
167
186
  * component root through {@link ValidateContext.list} instead, so the case is
168
187
  * actually reported.
169
188
  *
170
- * Deliberately TOP-LEVEL only: a package's own resource dirs
171
- * (`pdf-processing/reference/`) are part of the item, not candidate packages,
172
- * and flagging them would turn every conformant bundle red. A top-level dir
173
- * that holds a manifest ANYWHERE beneath it is a grouping dir, not a broken
174
- * package, so it is left alone too.
189
+ * A package's own resource dirs (`pdf-processing/reference/`) are part of the
190
+ * item, not candidate packages. Grouping directories are supported too, but
191
+ * their children are classified independently so one valid package cannot hide
192
+ * a manifest-less sibling.
175
193
  */
176
194
  async function missingManifestDiagnostics(ctx) {
177
195
  const diagnostics = [];
@@ -181,9 +199,7 @@ async function missingManifestDiagnostics(ctx) {
181
199
  const entries = await ctx.list(name);
182
200
  if (entries.length === 0)
183
201
  continue; // a root file (README.md), or an untrackable empty dir
184
- if (await subtreeHasManifest(name, entries, ctx, 1))
185
- continue;
186
- diagnostics.push({ file: name, issue: "missing-skill-md", detail: `no SKILL.md in ${name}/`, fixed: false });
202
+ diagnostics.push(...(await scanPackageCandidate(name, entries, ctx, 1)).diagnostics);
187
203
  }
188
204
  return diagnostics;
189
205
  }
@@ -52,7 +52,7 @@
52
52
  */
53
53
  import path from "node:path";
54
54
  import { isDangerousEnvKey } from "../../../commands/lint/env-key-rules.js";
55
- import { taskFieldProblems } from "../../../tasks/schema.js";
55
+ import { isPresentTarget, taskFieldProblems } from "../../../tasks/schema.js";
56
56
  import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
57
57
  import { parseWorkflow } from "../../../workflows/parser.js";
58
58
  import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
@@ -283,7 +283,11 @@ export function taskDiagnostics(relPath, data) {
283
283
  if (data === null || Object.keys(data).length === 0)
284
284
  return [];
285
285
  const missing = taskFieldProblems(data);
286
- const hasTarget = "prompt" in data || "workflow" in data || "command" in data;
286
+ // Presence, matching the runtime parser's rule (src/tasks/parser.ts): an
287
+ // empty string is NOT a target there, so a `workflow: ""` that linted clean
288
+ // here failed at run time with MISSING_REQUIRED_ARGUMENT — a file the linter
289
+ // called valid but that could never run.
290
+ const hasTarget = ["prompt", "workflow", "command"].some((key) => isPresentTarget(data[key]));
287
291
  if (!hasTarget)
288
292
  missing.push("prompt, workflow, or command");
289
293
  if (missing.length > 0) {
@@ -30,7 +30,7 @@
30
30
  */
31
31
  import fs from "node:fs";
32
32
  import path from "node:path";
33
- import { parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
33
+ import { isPresentTarget, parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
34
34
  import { hashContent } from "./shared.js";
35
35
  /** A native task bundle is single-component; its one component is `main`. */
36
36
  const COMPONENT_ID = "main";
@@ -71,7 +71,9 @@ function taskDiagnostics(relPath, data) {
71
71
  if (Object.keys(data).length === 0)
72
72
  return [];
73
73
  const problems = taskFieldProblems(data);
74
- const targets = TARGET_KEYS.filter((k) => k in data && data[k] !== undefined && data[k] !== null);
74
+ // Shared presence rule (src/tasks/schema.ts): an empty string or empty array
75
+ // is not a target, matching the runtime parser.
76
+ const targets = TARGET_KEYS.filter((k) => isPresentTarget(data[k]));
75
77
  if (targets.length === 0)
76
78
  problems.push("exactly one target (prompt, workflow, or command)");
77
79
  else if (targets.length > 1)
@@ -61,6 +61,25 @@ function classify(relPath) {
61
61
  }
62
62
  return null;
63
63
  }
64
+ /**
65
+ * True when `--sensitive` marked this asset, via the sibling marker file that
66
+ * `akm env create --sensitive` / `akm secret create --sensitive` writes:
67
+ * `env/<name>.sensitive` for `env/<name>.env`, `secrets/<name>.sensitive` for
68
+ * `secrets/<name>`.
69
+ *
70
+ * The flag documents itself as excluding the asset from BOTH `env list` output
71
+ * and the search index. Indexing filters are adapter-owned (the walk no longer
72
+ * pre-filters), and the akm adapter abstains on the marker — but this adapter
73
+ * only skipped files whose OWN name ended in `.sensitive`. A dotenv bundle is a
74
+ * legal env/secret write target, so a marked `env/prod.env` there was still
75
+ * indexed with every KEY NAME as a hint and a marked secret still indexed by
76
+ * name, while `env list` / `secret list` correctly hid them. The two surfaces
77
+ * disagreed about a documented promise.
78
+ */
79
+ function hasSensitiveMarker(absPath, type) {
80
+ const marker = type === "env" ? absPath.replace(/\.env$/i, ".sensitive") : `${absPath}.sensitive`;
81
+ return marker !== absPath && fs.existsSync(marker);
82
+ }
64
83
  /** Extract KEY NAMES (never values) from an env file's raw content, first-appearance order, deduped. */
65
84
  function scanKeyNames(raw) {
66
85
  const keys = [];
@@ -81,6 +100,8 @@ function recognize(c, file) {
81
100
  const type = classify(file.relPath);
82
101
  if (type === null)
83
102
  return null;
103
+ if (hasSensitiveMarker(file.absPath, type))
104
+ return null;
84
105
  const posix = toPosix(file.relPath);
85
106
  const raw = file.content();
86
107
  if (type === "env") {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import fs from "node:fs";
12
12
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
13
+ import { existingFileMode, writeFileAtomic } from "../common.js";
13
14
  import { recordWrittenPath } from "../write-provenance.js";
14
15
  import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
15
16
  /**
@@ -143,7 +144,11 @@ export function mutateFrontmatter(filePath, mutator) {
143
144
  const next = parsed.frontmatter !== null
144
145
  ? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
145
146
  : assembleAsset(nextFrontmatter, parsed.content);
146
- fs.writeFileSync(filePath, next, "utf8");
147
+ // Atomic, like the canonical asset write: this rewrites a file the user
148
+ // authored, and a truncate-in-place left a window where a crash or a
149
+ // concurrent reader saw a half-written or empty asset. The existing mode is
150
+ // preserved so stamping frontmatter never changes an asset's permissions.
151
+ writeFileAtomic(filePath, next, existingFileMode(filePath));
147
152
  // #652: in-place frontmatter stamps (belief state, contradiction markers,
148
153
  // salience) are real asset mutations — journal them for the run's sync.
149
154
  recordWrittenPath(filePath);
@@ -83,6 +83,75 @@ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
83
83
  * don't fail on those mounts. Windows does not support opening a
84
84
  * directory for fsync, so the directory-sync step is skipped there.
85
85
  */
86
+ /**
87
+ * Strip JavaScript-style comments from a JSON string (JSONC support).
88
+ * Handles `//` line comments and `/* *​/` block comments while preserving
89
+ * comment-like sequences inside quoted strings.
90
+ */
91
+ export function stripJsonComments(text) {
92
+ let result = "";
93
+ let i = 0;
94
+ let inString = false;
95
+ while (i < text.length) {
96
+ if (inString) {
97
+ if (text[i] === "\\") {
98
+ result += text[i] + (text[i + 1] ?? "");
99
+ i += 2;
100
+ continue;
101
+ }
102
+ if (text[i] === '"') {
103
+ inString = false;
104
+ }
105
+ result += text[i];
106
+ i++;
107
+ continue;
108
+ }
109
+ if (text[i] === '"') {
110
+ inString = true;
111
+ result += text[i];
112
+ i++;
113
+ continue;
114
+ }
115
+ if (text[i] === "/" && text[i + 1] === "/") {
116
+ while (i < text.length && text[i] !== "\n")
117
+ i++;
118
+ continue;
119
+ }
120
+ if (text[i] === "/" && text[i + 1] === "*") {
121
+ i += 2;
122
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
123
+ i++;
124
+ i += 2;
125
+ continue;
126
+ }
127
+ result += text[i];
128
+ i++;
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * The mode to rewrite an existing USER-owned file with.
134
+ *
135
+ * {@link writeFileAtomic} creates its temp file with an explicit mode and
136
+ * chmods it, so it needs one — and its 0600 default is right for akm's own
137
+ * state but wrong for a user's asset, where rewriting must never change
138
+ * permissions. Returns the file's current mode, or the umask-derived default
139
+ * `fs.writeFileSync` would have produced for a new file.
140
+ */
141
+ export function existingFileMode(filePath) {
142
+ try {
143
+ return fs.statSync(filePath).mode & 0o777;
144
+ }
145
+ catch {
146
+ // Absent (a new asset) or unreadable: fall back to the default create mode.
147
+ try {
148
+ return 0o666 & ~process.umask();
149
+ }
150
+ catch {
151
+ return 0o644;
152
+ }
153
+ }
154
+ }
86
155
  export function writeFileAtomic(target, content, mode) {
87
156
  const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
88
157
  const data = typeof content === "string" ? Buffer.from(content) : content;
@@ -226,7 +295,11 @@ function readStashDirFromConfig() {
226
295
  try {
227
296
  const configPath = getConfigPath();
228
297
  const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
229
- const raw = JSON.parse(text);
298
+ // The config loader accepts JSONC, so a commented config.json is valid and
299
+ // in use. Parsing it raw here threw, the catch swallowed it, and every
300
+ // caller silently fell back — operating on the wrong bundle or failing with
301
+ // STASH_DIR_NOT_FOUND despite a perfectly good config.
302
+ const raw = JSON.parse(stripJsonComments(text));
230
303
  if (typeof raw !== "object" || raw === null)
231
304
  return undefined;
232
305
  // 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
@@ -778,14 +851,19 @@ export function stringArray(value) {
778
851
  * Return true if a process with the given PID is currently alive.
779
852
  * Uses `process.kill(pid, 0)` which does not deliver a signal but
780
853
  * throws ESRCH when the process does not exist.
854
+ *
855
+ * EPERM means the process EXISTS but belongs to another uid, so it must be
856
+ * reported alive. Treating it as dead let a lock held by a live process in a
857
+ * shared data dir (agent sandboxes, containers, service accounts — a
858
+ * configuration managed-db.ts explicitly supports) be reclaimed as stale.
781
859
  */
782
860
  export function isProcessAlive(pid) {
783
861
  try {
784
862
  process.kill(pid, 0);
785
863
  return true;
786
864
  }
787
- catch {
788
- return false;
865
+ catch (err) {
866
+ return err?.code === "EPERM";
789
867
  }
790
868
  }
791
869
  /**
@@ -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, writeFileAtomic } from "../common.js";
18
+ import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, 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";
@@ -233,48 +233,8 @@ export function withConfigLock(fn) {
233
233
  }
234
234
  }
235
235
  /**
236
- * Strip JavaScript-style comments from a JSON string (JSONC support).
237
- * Handles `//` line comments and `/* *​/` block comments while preserving
238
- * comment-like sequences inside quoted strings.
236
+ * Re-exported from core/common.ts, where it now lives so `resolveStashDir`
237
+ * can strip comments too without importing this module (common cannot depend
238
+ * on config-io config-io already depends on common).
239
239
  */
240
- export function stripJsonComments(text) {
241
- let result = "";
242
- let i = 0;
243
- let inString = false;
244
- while (i < text.length) {
245
- if (inString) {
246
- if (text[i] === "\\") {
247
- result += text[i] + (text[i + 1] ?? "");
248
- i += 2;
249
- continue;
250
- }
251
- if (text[i] === '"') {
252
- inString = false;
253
- }
254
- result += text[i];
255
- i++;
256
- continue;
257
- }
258
- if (text[i] === '"') {
259
- inString = true;
260
- result += text[i];
261
- i++;
262
- continue;
263
- }
264
- if (text[i] === "/" && text[i + 1] === "/") {
265
- while (i < text.length && text[i] !== "\n")
266
- i++;
267
- continue;
268
- }
269
- if (text[i] === "/" && text[i + 1] === "*") {
270
- i += 2;
271
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
272
- i++;
273
- i += 2;
274
- continue;
275
- }
276
- result += text[i];
277
- i++;
278
- }
279
- return result;
280
- }
240
+ export { stripJsonComments } from "../common.js";
@@ -14,7 +14,18 @@ import { z } from "zod";
14
14
  // `typeof import("./config-schema")` — routing through config-types would mint
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
+ import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
17
18
  import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, LlmCapabilitiesSchema, ModelAliasMapSchema, nonEmptyString, positiveInt, } from "./primitives.js";
19
+ /**
20
+ * Engine-config timeouts share the workflow ceiling.
21
+ *
22
+ * 0.9.1 bounded workflow-authored timeouts (parser) and frozen invocations
23
+ * (decoder) at 2^31-1, but the third source — `engines.<name>.timeoutMs` — was
24
+ * validated only as a positive integer. A larger value passed config validation
25
+ * and then failed EVERY run of that engine with an unlocated "Invalid frozen
26
+ * workflow plan: invocation is invalid". Reject it where the value is written.
27
+ */
28
+ const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS), z.null()]).optional();
18
29
  // ── Connection configs (LLM) ────────────────────────────────────────────────
19
30
  /**
20
31
  * OpenAI-compatible connection fields shared by named LLM engines and bounded
@@ -32,7 +43,7 @@ export const LlmConnectionConfigSchema = z
32
43
  apiKey: z.string().optional(),
33
44
  temperature: z.number().finite().optional(),
34
45
  maxTokens: positiveInt.optional(),
35
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
46
+ timeoutMs: timeoutMsField,
36
47
  concurrency: positiveInt.optional(),
37
48
  capabilities: LlmCapabilitiesSchema.optional(),
38
49
  extraParams: ExtraParamsSchema.optional(),
@@ -56,7 +67,7 @@ const LlmEngineSchema = z
56
67
  apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
57
68
  temperature: z.number().finite().optional(),
58
69
  maxTokens: positiveInt.optional(),
59
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
70
+ timeoutMs: timeoutMsField,
60
71
  concurrency: positiveInt.optional(),
61
72
  supportsJsonSchema: z.boolean().optional(),
62
73
  extraParams: ExtraParamsSchema.optional(),
@@ -80,7 +91,7 @@ const AgentEngineSchema = z
80
91
  args: z.array(z.string()).optional(),
81
92
  workspace: nonEmptyString.optional(),
82
93
  model: nonEmptyString.optional(),
83
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
94
+ timeoutMs: timeoutMsField,
84
95
  modelAliases: ModelAliasMapSchema.optional(),
85
96
  llmEngine: engineName.optional(),
86
97
  })
@@ -33,8 +33,16 @@ export function validateExtraParams(value) {
33
33
  return [{ path: [], message: "must be an object" }];
34
34
  }
35
35
  const issues = [];
36
+ // A self-referential YAML anchor (`extraParams: &a { nested: *a }`) resolves
37
+ // to a genuinely cyclic object — the yaml package's alias-count guard does not
38
+ // catch cycles — so an unguarded walk overflowed the stack with a RangeError
39
+ // that escaped task parsing. Track visited containers and stop at a revisit.
40
+ const seen = new WeakSet();
36
41
  const visit = (entry, path) => {
37
42
  if (Array.isArray(entry)) {
43
+ if (seen.has(entry))
44
+ return;
45
+ seen.add(entry);
38
46
  entry.forEach((child, index) => {
39
47
  visit(child, [...path, index]);
40
48
  });
@@ -42,6 +50,9 @@ export function validateExtraParams(value) {
42
50
  }
43
51
  if (!entry || typeof entry !== "object")
44
52
  return;
53
+ if (seen.has(entry))
54
+ return;
55
+ seen.add(entry);
45
56
  for (const [key, child] of Object.entries(entry)) {
46
57
  const normalized = normalizeExtraParamKey(key);
47
58
  if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {