akm-cli 0.9.1-beta.1 → 0.9.1-beta.3

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 (64) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/dist/cli/parse-args.js +7 -1
  3. package/dist/commands/env/child-env.js +14 -0
  4. package/dist/commands/health/advisories.js +5 -5
  5. package/dist/commands/health/html-report.js +2 -2
  6. package/dist/commands/health/metrics.js +38 -22
  7. package/dist/commands/health/report-view-model.js +1 -1
  8. package/dist/commands/improve/consolidate.js +61 -9
  9. package/dist/commands/improve/eval-cases.js +2 -0
  10. package/dist/commands/improve/memory/memory-improve.js +1 -0
  11. package/dist/commands/lint/base-linter.js +93 -20
  12. package/dist/commands/lint/index.js +5 -1
  13. package/dist/commands/sources/add-cli.js +8 -2
  14. package/dist/commands/sources/migration-help.js +12 -3
  15. package/dist/commands/sources/self-update.js +9 -1
  16. package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
  17. package/dist/core/adapter/adapters/akm-lint.js +6 -2
  18. package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
  19. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  20. package/dist/core/asset/frontmatter.js +6 -1
  21. package/dist/core/common.js +81 -3
  22. package/dist/core/config/config-io.js +5 -45
  23. package/dist/core/config/schema/engines.js +14 -3
  24. package/dist/core/extra-params.js +11 -0
  25. package/dist/core/fs-txn.js +15 -2
  26. package/dist/core/json-schema.js +19 -2
  27. package/dist/core/paths.js +16 -2
  28. package/dist/core/redaction.js +22 -1
  29. package/dist/core/state-db.js +1 -0
  30. package/dist/core/write-source.js +26 -2
  31. package/dist/indexer/indexer.js +48 -9
  32. package/dist/indexer/search/db-search.js +17 -2
  33. package/dist/indexer/walk/walker.js +6 -1
  34. package/dist/integrations/agent/detect.js +13 -1
  35. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  36. package/dist/integrations/lockfile.js +10 -0
  37. package/dist/llm/client.js +14 -19
  38. package/dist/llm/embedder.js +23 -3
  39. package/dist/llm/embedders/remote.js +27 -2
  40. package/dist/output/html-render.js +40 -1
  41. package/dist/runtime.js +23 -1
  42. package/dist/scripts/akm-migrate-node.js +303 -107
  43. package/dist/scripts/akm-migrate.js +303 -107
  44. package/dist/setup/setup.js +22 -7
  45. package/dist/sources/providers/git-install.js +25 -2
  46. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  47. package/dist/storage/database.js +71 -12
  48. package/dist/storage/engines/sqlite-migrations.js +61 -2
  49. package/dist/storage/repositories/index-connection.js +11 -1
  50. package/dist/storage/repositories/index-meta-repository.js +11 -0
  51. package/dist/storage/repositories/index-schema.js +17 -2
  52. package/dist/storage/repositories/index-vec-repository.js +43 -5
  53. package/dist/storage/repositories/salience-repository.js +13 -12
  54. package/dist/storage/sqlite-pragmas.js +12 -1
  55. package/dist/tasks/runner.js +84 -7
  56. package/dist/tasks/scheduler-invocation.js +19 -0
  57. package/dist/tasks/schema.js +21 -1
  58. package/dist/text-import-hook.mjs +1 -1
  59. package/dist/workflows/exec/native-executor.js +8 -0
  60. package/dist/workflows/exec/step-work.js +10 -2
  61. package/dist/workflows/parser.js +26 -1
  62. package/package.json +1 -1
  63. package/schemas/akm-config.json +10 -5
  64. package/schemas/akm-workflow.json +7 -3
@@ -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)) {
@@ -81,7 +81,11 @@ export function txnFileHash(filePath) {
81
81
  return txnHash(fs.readFileSync(filePath));
82
82
  }
83
83
  export function fsyncTxnFile(filePath) {
84
- const fd = fs.openSync(filePath, "r");
84
+ // Open for WRITE. Windows implements fsync as FlushFileBuffers, which
85
+ // requires write access on the handle — a read-only descriptor fails with
86
+ // EACCES/EPERM, so every proposal accept and reject failed on that platform.
87
+ // POSIX accepts "r+" here just as readily as "r".
88
+ const fd = fs.openSync(filePath, "r+");
85
89
  try {
86
90
  fs.fsyncSync(fd);
87
91
  }
@@ -91,7 +95,16 @@ export function fsyncTxnFile(filePath) {
91
95
  }
92
96
  export function fsyncTxnDir(dirPath) {
93
97
  try {
94
- fsyncTxnFile(dirPath);
98
+ // Read-only, unlike {@link fsyncTxnFile}: a directory cannot be opened for
99
+ // write on POSIX (EISDIR), and on Windows this whole operation is
100
+ // unsupported anyway and falls into the catch.
101
+ const fd = fs.openSync(dirPath, "r");
102
+ try {
103
+ fs.fsyncSync(fd);
104
+ }
105
+ finally {
106
+ fs.closeSync(fd);
107
+ }
95
108
  }
96
109
  catch {
97
110
  // Directory fsync is unavailable on some platforms.
@@ -171,6 +171,13 @@ function pushIssue(issues, path, keyword, kind, message) {
171
171
  function isPlainObject(value) {
172
172
  return typeof value === "object" && value !== null && !Array.isArray(value);
173
173
  }
174
+ /** Values the runtime's reference-equality `enum` check can enforce correctly. */
175
+ function isSupportedEnumValue(value) {
176
+ return (value === null ||
177
+ typeof value === "string" ||
178
+ typeof value === "boolean" ||
179
+ (typeof value === "number" && Number.isFinite(value)));
180
+ }
174
181
  function checkDefinitionNode(schema, path, issues, depth) {
175
182
  if (depth > MAX_DEFINITION_DEPTH) {
176
183
  pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
@@ -199,8 +206,18 @@ function checkDefinitionNode(schema, path, issues, depth) {
199
206
  }
200
207
  }
201
208
  }
202
- if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
203
- pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
209
+ if (schema.enum !== undefined) {
210
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
211
+ pushIssue(issues, [...path, "enum"], "enum", "malformed", `"enum" must be a non-empty array of allowed values`);
212
+ }
213
+ else {
214
+ schema.enum.forEach((value, index) => {
215
+ if (isSupportedEnumValue(value))
216
+ return;
217
+ pushIssue(issues, [...path, "enum", index], "enum", "unsupported", `"enum" values must be JSON primitives (string, finite number, boolean, or null) in the workflow ` +
218
+ `schema subset — object and array enum members cannot be matched by the runtime subset`);
219
+ });
220
+ }
204
221
  }
205
222
  for (const keyword of ["allOf", "anyOf", "oneOf"]) {
206
223
  const branches = schema[keyword];
@@ -160,9 +160,23 @@ export function getCacheDir(env = process.env) {
160
160
  }
161
161
  const home = env.HOME?.trim();
162
162
  if (!home)
163
- return path.join("/tmp", "akm-cache");
163
+ return homelessFallbackDir("akm-cache");
164
164
  return path.join(home, ".cache", "akm");
165
165
  }
166
+ /**
167
+ * Last-resort directory when neither the XDG variable nor HOME is set.
168
+ *
169
+ * Scoped by uid. A fixed `/tmp/akm-<kind>` path is world-shared and entirely
170
+ * predictable: on a multi-user host the first uid to run akm owns the
171
+ * directory and every other user then reads and writes the same databases, and
172
+ * any local user can pre-create the path (or a symlink at it) and wait. Adding
173
+ * the uid gives each account its own path; the caller still creates it with
174
+ * restrictive permissions.
175
+ */
176
+ function homelessFallbackDir(kind) {
177
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
178
+ return path.join(os.tmpdir(), uid === undefined ? kind : `${kind}-${uid}`);
179
+ }
166
180
  // ── Data directory ───────────────────────────────────────────────────────────
167
181
  /**
168
182
  * Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
@@ -209,7 +223,7 @@ export function getDataDir(env = process.env, platform = process.platform) {
209
223
  return path.join(xdgDataHome, "akm");
210
224
  const home = env.HOME?.trim();
211
225
  if (!home)
212
- return path.join("/tmp", "akm-data");
226
+ return homelessFallbackDir("akm-data");
213
227
  return path.join(home, ".local", "share", "akm");
214
228
  }
215
229
  export function getDbPath(env = process.env) {
@@ -322,7 +322,7 @@ function addPlainMatches(coverageDelta, text, needle) {
322
322
  * unlike {@link redactSensitiveText}, which requires the exact secret value
323
323
  * up front, this catches credentials no caller ever knew to list. No
324
324
  * truncation is applied; callers that need a length cap (e.g.
325
- * {@link redactErrorBody} in src/llm/client.ts) apply it themselves.
325
+ * {@link redactErrorBody}) apply it themselves.
326
326
  *
327
327
  * Targets:
328
328
  * - `Bearer <token>` headers echoed back by a provider
@@ -367,6 +367,27 @@ export function redactCredentialPatterns(input) {
367
367
  * that is a memory-exhaustion hazard reachable from ordinary command output.
368
368
  * The encoded-form path never had the bug because it always worked this way.
369
369
  */
370
+ /** Max characters of a provider error body worth surfacing in a message. */
371
+ const ERROR_BODY_MAX_LEN = 200;
372
+ /**
373
+ * Make an HTTP error body safe to put in an error message: pattern-redact
374
+ * credential shapes, then clip. Provider bodies can echo the credential that
375
+ * was sent and can be megabytes of HTML, and these messages travel — into
376
+ * persisted status files, `--json` output, and agent transcripts.
377
+ *
378
+ * Lives here rather than beside one transport because every HTTP client in the
379
+ * codebase needs it; the embeddings transport originally lacked it and leaked
380
+ * raw 10 MB bodies into `semantic-status.json`.
381
+ */
382
+ export function redactErrorBody(input) {
383
+ if (!input)
384
+ return "";
385
+ let out = redactCredentialPatterns(input);
386
+ if (out.length > ERROR_BODY_MAX_LEN) {
387
+ out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
388
+ }
389
+ return out;
390
+ }
370
391
  export function redactSensitiveText(text, sensitiveValues) {
371
392
  const values = [...new Set(sensitiveValues)]
372
393
  .filter((value) => value.length > 0)
@@ -150,6 +150,7 @@ export function openStateDatabase(dbPath) {
150
150
  exec: db.exec.bind(db),
151
151
  run: db.run.bind(db),
152
152
  transaction: db.transaction.bind(db),
153
+ loadExtension: db.loadExtension.bind(db),
153
154
  get inTransaction() {
154
155
  return db.inTransaction;
155
156
  },
@@ -33,7 +33,7 @@ import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
33
33
  import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
34
34
  import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
35
35
  import { deriveBundleId } from "./bundle-id.js";
36
- import { isWithin, resolveStashDir } from "./common.js";
36
+ import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
37
37
  import { resolveConfiguredSources } from "./config/config.js";
38
38
  import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
@@ -333,7 +333,11 @@ export async function writeAssetToSource(source, config, ref, content) {
333
333
  const preflight = preflightGitPathMutation(source, filePath);
334
334
  try {
335
335
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
336
- fs.writeFileSync(filePath, normalized, "utf8");
336
+ // Atomic: truncate-and-rewrite left a window in which a crash, a full disk,
337
+ // or a concurrent reader saw a half-written or empty asset — destroying user
338
+ // content that was fine a moment earlier. writeFileAtomic writes a sibling
339
+ // temp file, fdatasyncs it, and renames over the target.
340
+ writeFileAtomic(filePath, normalized, existingFileMode(filePath));
337
341
  recordWriteTargetPath(source, filePath);
338
342
  // #652: run-scoped write provenance — the canonical asset write is the
339
343
  // single largest contributor to an improve run's written-path set.
@@ -1003,11 +1007,31 @@ function ensureWritable(source, config) {
1003
1007
  throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
1004
1008
  }
1005
1009
  }
1010
+ /**
1011
+ * MS-DOS device names Windows still reserves in every directory, with or
1012
+ * without an extension (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
1013
+ */
1014
+ const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
1015
+ "con",
1016
+ "prn",
1017
+ "aux",
1018
+ "nul",
1019
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
1020
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
1021
+ ]);
1006
1022
  function resolveAssetFilePath(source, ref) {
1007
1023
  const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
1008
1024
  if (basename === "index" || basename === "log") {
1009
1025
  throw new UsageError(`Reserved concept name "${basename}" cannot be written.`, "INVALID_FLAG_VALUE");
1010
1026
  }
1027
+ // Windows resolves these names as DEVICES no matter the directory or the
1028
+ // extension, so `CON.md` is not a file — a write goes to the console and a
1029
+ // read blocks on console input. Rejected on every platform so a stash stays
1030
+ // portable: an asset authored on Linux must not become unopenable when the
1031
+ // same bundle is used on Windows.
1032
+ if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
1033
+ throw new UsageError(`Asset name "${basename}" is a reserved Windows device name and cannot be written.`, "INVALID_FLAG_VALUE");
1034
+ }
1011
1035
  const typeDir = stashDirFor(ref.type);
1012
1036
  if (!typeDir) {
1013
1037
  throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");