akm-cli 0.9.0 → 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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -14,22 +14,288 @@
14
14
  * Supported: `type` (string | string[] — string, number, integer, boolean,
15
15
  * object, array, null), `properties`, `required`, `items`,
16
16
  * `additionalProperties: false`, `enum` (primitives), `minItems`,
17
- * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`.
17
+ * `maxItems`, `minLength`, `maxLength`, `minimum`, `maximum`, and the
18
+ * combinators `allOf`, `anyOf`, `oneOf`, `not`.
18
19
  *
19
- * Ignored (permissive): `$ref`, `allOf`/`anyOf`/`oneOf`/`not`, `pattern`,
20
- * `format`, and every other keyword. Unknown keywords never throw — a
21
- * schema using them simply constrains less. Callers needing full JSON
22
- * Schema semantics should validate downstream.
20
+ * Ignored (permissive): `$ref`, `pattern`, `format`, `patternProperties`,
21
+ * tuple-form `items`, schema-form `additionalProperties`, and every other
22
+ * keyword. Unknown keywords never throw a schema using them simply
23
+ * constrains less. Callers needing full JSON Schema semantics should
24
+ * validate downstream.
25
+ *
26
+ * `pattern` is deliberately NOT evaluated. Matching an author-supplied regex
27
+ * with the platform `RegExp` inside a synchronous gate decision would need a
28
+ * static safety analysis to stay bounded, and any such analysis rejects
29
+ * legitimate patterns — machinery that makes authoring fail for no benefit
30
+ * the workflows here ask for. `pattern` is a recognized-but-unsupported
31
+ * keyword instead: {@link checkJsonSchemaDefinition} reports it loudly at
32
+ * authoring time, so nobody writes one believing it is enforced.
33
+ *
34
+ * ## Totality and bounds
35
+ *
36
+ * Evaluation is TOTAL and BOUNDED. Recursion is capped at
37
+ * {@link MAX_DEFINITION_DEPTH} and the whole evaluation shares a single
38
+ * node-visit budget ({@link MAX_VALIDATION_NODES}); exhausting either emits an
39
+ * explicit error rather than silently accepting the value — the subset never
40
+ * fails open. The schema tree is finite and acyclic (no `$ref`), so combinator
41
+ * branching multiplies work by schema size, never exponentially.
23
42
  *
24
43
  * Returns a flat list of human-readable error strings (empty = valid), each
25
44
  * prefixed with a JSON-pointer-ish path — the shape `runStructured`'s
26
45
  * corrective-feedback builder wants.
46
+ *
47
+ * {@link checkJsonSchemaDefinition} is the companion DEFINITION checker: it
48
+ * walks an author-declared schema OBJECT (not a value) and reports typo'd
49
+ * `type` names / structurally malformed keywords as `"malformed"` issues, and
50
+ * recognized JSON Schema keywords this subset silently ignores as
51
+ * `"unsupported"` issues — so a schema that would constrain nothing at
52
+ * runtime can be rejected loudly at authoring time (the workflow parser does
53
+ * exactly that for `output:` and `params` schemas). It deliberately does NOT
54
+ * change {@link validateJsonSchemaSubset}'s permissive evaluation semantics.
55
+ */
56
+ /**
57
+ * Deepest schema nesting either walk descends into — {@link
58
+ * validateJsonSchemaSubset} evaluating a value, and {@link
59
+ * checkJsonSchemaDefinition} checking the schema object itself. Both walk the
60
+ * same schema tree, so they share one bound.
27
61
  */
62
+ const MAX_DEFINITION_DEPTH = 64;
63
+ /** Total (schema node × value node) visits one {@link validateJsonSchemaSubset} call may make. */
64
+ const MAX_VALIDATION_NODES = 100_000;
28
65
  export function validateJsonSchemaSubset(value, schema) {
29
66
  const errors = [];
30
- validateNode(value, schema, "$", errors);
67
+ const budget = { nodes: MAX_VALIDATION_NODES };
68
+ validateNode(value, schema, "$", { errors, budget, depth: 0 });
69
+ if (budget.nodes < 0) {
70
+ errors.push(`$: schema evaluation exceeded the limit of ${MAX_VALIDATION_NODES} checks and was stopped`);
71
+ }
31
72
  return errors;
32
73
  }
74
+ /** Human-readable list of the keywords {@link validateJsonSchemaSubset} enforces (for error messages). */
75
+ export const JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS = "type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, " +
76
+ "minLength, maxLength, minimum, maximum, allOf, anyOf, oneOf, not";
77
+ const KNOWN_TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
78
+ // Annotation keywords (`title`, `description`, `default`, `examples`,
79
+ // `$schema`, `$id`, `$comment`, `deprecated`, `readOnly`, `writeOnly`) are
80
+ // deliberately NOT in the unsupported set below: they constrain nothing in
81
+ // full JSON Schema either, so the subset ignoring them loses no semantics.
82
+ // Like any other unrecognized keyword (e.g. `x-…` extensions), they fall
83
+ // through the checker unreported — JSON Schema's own open-keyword behavior.
84
+ /**
85
+ * Recognized JSON Schema keywords {@link validateJsonSchemaSubset} silently
86
+ * ignores — a schema relying on one of these constrains LESS at runtime than
87
+ * its author intended, so definition checking reports each as `"unsupported"`.
88
+ */
89
+ const UNSUPPORTED_KEYWORDS = new Set([
90
+ "$ref",
91
+ "$defs",
92
+ "definitions",
93
+ "$anchor",
94
+ "$dynamicRef",
95
+ "$dynamicAnchor",
96
+ "if",
97
+ "then",
98
+ "else",
99
+ "const",
100
+ "pattern",
101
+ "format",
102
+ "patternProperties",
103
+ "propertyNames",
104
+ "additionalItems",
105
+ "prefixItems",
106
+ "contains",
107
+ "minContains",
108
+ "maxContains",
109
+ "uniqueItems",
110
+ "multipleOf",
111
+ "exclusiveMinimum",
112
+ "exclusiveMaximum",
113
+ "minProperties",
114
+ "maxProperties",
115
+ "dependencies",
116
+ "dependentRequired",
117
+ "dependentSchemas",
118
+ "unevaluatedItems",
119
+ "unevaluatedProperties",
120
+ "contentEncoding",
121
+ "contentMediaType",
122
+ "contentSchema",
123
+ ]);
124
+ /**
125
+ * Per-keyword follow-up for the `"unsupported"` message. Where the subset can
126
+ * express the same intent, name the keyword that does it; where it cannot, say
127
+ * so plainly rather than sending the author to another unsupported keyword.
128
+ */
129
+ const UNSUPPORTED_KEYWORD_HINTS = new Map([
130
+ ["$ref", `inline the referenced schema (the subset resolves no references, so it cannot follow "$ref")`],
131
+ ["$defs", `inline the definitions at their use sites — "$ref" is not resolved, so "$defs" can never be reached`],
132
+ [
133
+ "definitions",
134
+ `inline the definitions at their use sites — "$ref" is not resolved, so "definitions" can never be reached`,
135
+ ],
136
+ ["const", `use a single-value "enum" (e.g. enum: [pass])`],
137
+ [
138
+ "pattern",
139
+ `use "enum" when the allowed strings can be listed, or "minLength"/"maxLength" for a size bound — ` +
140
+ `a regular-expression constraint is not expressible in the subset; check the shape in the step's gate rubric instead`,
141
+ ],
142
+ [
143
+ "format",
144
+ `"format" is annotation-only in JSON Schema 2020-12 and the subset enforces no string-shape keyword — ` +
145
+ `use "enum" when the allowed values can be listed, otherwise check the shape in the step's gate rubric`,
146
+ ],
147
+ ["patternProperties", `declare the properties explicitly under "properties", or drop the constraint`],
148
+ ["if", `use "anyOf"/"oneOf" to express the alternatives directly`],
149
+ ["then", `use "anyOf"/"oneOf" to express the alternatives directly`],
150
+ ["else", `use "anyOf"/"oneOf" to express the alternatives directly`],
151
+ ["uniqueItems", `drop the constraint, or validate uniqueness in the step's gate rubric`],
152
+ ]);
153
+ /**
154
+ * Check a JSON Schema DEFINITION (the schema object itself, not a value)
155
+ * against the subset {@link validateJsonSchemaSubset} enforces. Returns
156
+ * accumulated issues (empty = the schema is a well-formed subset schema).
157
+ * Keywords that are neither subset-enforced, unsupported-but-recognized, nor
158
+ * annotations are ignored, matching JSON Schema's own open-keyword behavior.
159
+ */
160
+ export function checkJsonSchemaDefinition(schema) {
161
+ const issues = [];
162
+ checkDefinitionNode(schema, [], issues, 0);
163
+ return issues;
164
+ }
165
+ function pointerFor(path) {
166
+ return path.length === 0 ? "$" : `$.${path.map(String).join(".")}`;
167
+ }
168
+ function pushIssue(issues, path, keyword, kind, message) {
169
+ issues.push({ path: [...path], pointer: pointerFor(path), keyword, kind, message });
170
+ }
171
+ function isPlainObject(value) {
172
+ return typeof value === "object" && value !== null && !Array.isArray(value);
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
+ }
181
+ function checkDefinitionNode(schema, path, issues, depth) {
182
+ if (depth > MAX_DEFINITION_DEPTH) {
183
+ pushIssue(issues, path, "(depth)", "malformed", `schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
184
+ return;
185
+ }
186
+ for (const keyword of Object.keys(schema)) {
187
+ if (UNSUPPORTED_KEYWORDS.has(keyword)) {
188
+ const hint = UNSUPPORTED_KEYWORD_HINTS.get(keyword);
189
+ pushIssue(issues, [...path, keyword], keyword, "unsupported", `keyword "${keyword}" is not enforced by the workflow schema subset — the schema would silently not ` +
190
+ `constrain what it looks like it constrains${hint ? `; ${hint}` : ""}`);
191
+ }
192
+ }
193
+ const declared = schema.type;
194
+ if (declared !== undefined) {
195
+ const names = Array.isArray(declared) ? declared : [declared];
196
+ if (names.length === 0) {
197
+ pushIssue(issues, [...path, "type"], "type", "malformed", `"type" must name at least one type`);
198
+ }
199
+ for (const [index, name] of names.entries()) {
200
+ const namePath = Array.isArray(declared) ? [...path, "type", index] : [...path, "type"];
201
+ if (typeof name !== "string") {
202
+ pushIssue(issues, namePath, "type", "malformed", `"type" must be a string or an array of strings`);
203
+ }
204
+ else if (!KNOWN_TYPE_NAMES.has(name)) {
205
+ pushIssue(issues, namePath, "type", "malformed", `unknown type ${JSON.stringify(name)} (valid types: ${[...KNOWN_TYPE_NAMES].join(", ")})`);
206
+ }
207
+ }
208
+ }
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
+ }
221
+ }
222
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
223
+ const branches = schema[keyword];
224
+ if (branches === undefined)
225
+ continue;
226
+ if (!Array.isArray(branches) || branches.length === 0) {
227
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-empty array of schema objects`);
228
+ continue;
229
+ }
230
+ branches.forEach((branch, index) => {
231
+ if (isPlainObject(branch)) {
232
+ checkDefinitionNode(branch, [...path, keyword, index], issues, depth + 1);
233
+ }
234
+ else {
235
+ pushIssue(issues, [...path, keyword, index], keyword, "malformed", `"${keyword}[${index}]" must be a schema object`);
236
+ }
237
+ });
238
+ }
239
+ if (schema.not !== undefined) {
240
+ if (isPlainObject(schema.not)) {
241
+ checkDefinitionNode(schema.not, [...path, "not"], issues, depth + 1);
242
+ }
243
+ else {
244
+ pushIssue(issues, [...path, "not"], "not", "malformed", `"not" must be a schema object`);
245
+ }
246
+ }
247
+ if (schema.required !== undefined) {
248
+ if (!Array.isArray(schema.required) || !schema.required.every((key) => typeof key === "string")) {
249
+ pushIssue(issues, [...path, "required"], "required", "malformed", `"required" must be an array of property-name strings`);
250
+ }
251
+ }
252
+ if (schema.properties !== undefined) {
253
+ if (!isPlainObject(schema.properties)) {
254
+ pushIssue(issues, [...path, "properties"], "properties", "malformed", `"properties" must be an object mapping property names to schemas`);
255
+ }
256
+ else {
257
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
258
+ if (isPlainObject(propSchema)) {
259
+ checkDefinitionNode(propSchema, [...path, "properties", key], issues, depth + 1);
260
+ }
261
+ else {
262
+ pushIssue(issues, [...path, "properties", key], "properties", "malformed", `property ${JSON.stringify(key)} must be a schema object`);
263
+ }
264
+ }
265
+ }
266
+ }
267
+ if (schema.items !== undefined) {
268
+ if (isPlainObject(schema.items)) {
269
+ checkDefinitionNode(schema.items, [...path, "items"], issues, depth + 1);
270
+ }
271
+ else if (Array.isArray(schema.items)) {
272
+ pushIssue(issues, [...path, "items"], "items", "unsupported", `tuple-form "items" (an array of schemas) is not enforced by the workflow schema subset — use a single schema object`);
273
+ }
274
+ else {
275
+ pushIssue(issues, [...path, "items"], "items", "malformed", `"items" must be a schema object`);
276
+ }
277
+ }
278
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
279
+ if (isPlainObject(schema.additionalProperties)) {
280
+ pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "unsupported", `schema-form "additionalProperties" is not enforced by the workflow schema subset — only "additionalProperties: false" is`);
281
+ }
282
+ else {
283
+ pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "malformed", `"additionalProperties" must be a boolean (only "false" is enforced)`);
284
+ }
285
+ }
286
+ for (const keyword of ["minItems", "maxItems", "minLength", "maxLength"]) {
287
+ const value = schema[keyword];
288
+ if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < 0)) {
289
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a non-negative integer`);
290
+ }
291
+ }
292
+ for (const keyword of ["minimum", "maximum"]) {
293
+ const value = schema[keyword];
294
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) {
295
+ pushIssue(issues, [...path, keyword], keyword, "malformed", `"${keyword}" must be a finite number`);
296
+ }
297
+ }
298
+ }
33
299
  function typeOf(value) {
34
300
  if (value === null)
35
301
  return "null";
@@ -52,7 +318,66 @@ function matchesType(actual, expected) {
52
318
  // JSON Schema: every integer is also a number.
53
319
  return expected === "number" && actual === "integer";
54
320
  }
55
- function validateNode(value, schema, path, errors) {
321
+ /** Evaluate `schema` against `value` in a scratch error list, sharing the caller's budget. */
322
+ function branchErrors(value, schema, path, ctx) {
323
+ const errors = [];
324
+ validateNode(value, schema, path, { ...ctx, errors, depth: ctx.depth + 1 });
325
+ return errors;
326
+ }
327
+ /** The schemas of a combinator keyword, or `[]` when the keyword is absent/malformed (permissive). */
328
+ function combinatorBranches(schema, keyword) {
329
+ const raw = schema[keyword];
330
+ if (!Array.isArray(raw))
331
+ return [];
332
+ return raw.filter(isPlainObject);
333
+ }
334
+ /** First error of each failing branch, truncated — enough to act on without dumping every branch. */
335
+ function summarizeBranchFailures(failures) {
336
+ const shown = failures.slice(0, 3).map((f) => `${f.index + 1}: ${f.errors[0] ?? "no match"}`);
337
+ if (failures.length > shown.length)
338
+ shown.push(`…${failures.length - shown.length} more`);
339
+ return shown.join("; ");
340
+ }
341
+ function validateCombinators(value, schema, path, ctx) {
342
+ for (const branch of combinatorBranches(schema, "allOf")) {
343
+ // `allOf` failures ARE the value's failures — surface them verbatim.
344
+ ctx.errors.push(...branchErrors(value, branch, path, ctx));
345
+ }
346
+ for (const keyword of ["anyOf", "oneOf"]) {
347
+ const branches = combinatorBranches(schema, keyword);
348
+ if (branches.length === 0)
349
+ continue;
350
+ const failures = [];
351
+ const matched = [];
352
+ branches.forEach((branch, index) => {
353
+ const errors = branchErrors(value, branch, path, ctx);
354
+ if (errors.length === 0)
355
+ matched.push(index + 1);
356
+ else
357
+ failures.push({ index, errors });
358
+ });
359
+ if (matched.length === 0) {
360
+ ctx.errors.push(`${path}: value matches none of the ${branches.length} "${keyword}" schemas (${summarizeBranchFailures(failures)})`);
361
+ }
362
+ else if (keyword === "oneOf" && matched.length > 1) {
363
+ ctx.errors.push(`${path}: value matches ${matched.length} "oneOf" schemas (branches ${matched.join(", ")}); exactly one must match`);
364
+ }
365
+ }
366
+ const not = schema.not;
367
+ if (isPlainObject(not) && branchErrors(value, not, path, ctx).length === 0) {
368
+ ctx.errors.push(`${path}: value must not match the "not" schema`);
369
+ }
370
+ }
371
+ function validateNode(value, schema, path, ctx) {
372
+ const errors = ctx.errors;
373
+ if (ctx.depth > MAX_DEFINITION_DEPTH) {
374
+ errors.push(`${path}: schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
375
+ return;
376
+ }
377
+ // Fail CLOSED: a truncated evaluation never returns "valid" — the counter
378
+ // going negative is what the wrapper turns into a top-level error.
379
+ if (--ctx.budget.nodes < 0)
380
+ return;
56
381
  const actual = typeOf(value);
57
382
  const declared = schema.type;
58
383
  if (typeof declared === "string" || Array.isArray(declared)) {
@@ -69,6 +394,10 @@ function validateNode(value, schema, path, errors) {
69
394
  return;
70
395
  }
71
396
  }
397
+ // Combinators are type-agnostic, so they run BEFORE the per-type branches
398
+ // below (each of which returns). A schema with no combinator keyword is
399
+ // untouched by this call.
400
+ validateCombinators(value, schema, path, ctx);
72
401
  if (actual === "string" && typeof value === "string") {
73
402
  if (typeof schema.minLength === "number" && value.length < schema.minLength) {
74
403
  errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
@@ -97,7 +426,10 @@ function validateNode(value, schema, path, errors) {
97
426
  const items = schema.items;
98
427
  if (items && typeof items === "object" && !Array.isArray(items)) {
99
428
  value.forEach((element, index) => {
100
- validateNode(element, items, `${path}[${index}]`, errors);
429
+ validateNode(element, items, `${path}[${index}]`, {
430
+ ...ctx,
431
+ depth: ctx.depth + 1,
432
+ });
101
433
  });
102
434
  }
103
435
  return;
@@ -122,7 +454,10 @@ function validateNode(value, schema, path, errors) {
122
454
  if (!Object.hasOwn(record, key))
123
455
  continue;
124
456
  if (propSchema && typeof propSchema === "object" && !Array.isArray(propSchema)) {
125
- validateNode(record[key], propSchema, `${path}.${key}`, errors);
457
+ validateNode(record[key], propSchema, `${path}.${key}`, {
458
+ ...ctx,
459
+ depth: ctx.depth + 1,
460
+ });
126
461
  }
127
462
  }
128
463
  }
@@ -0,0 +1,89 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Loopback classification for LLM endpoints: does this endpoint name a model
6
+ * server running on THIS machine? A host is loopback when it is `localhost` or
7
+ * a `*.localhost` name (RFC 6761 §6.3), anywhere in `127.0.0.0/8`, an
8
+ * unspecified address (`0.0.0.0` / `::` — a client connecting there reaches
9
+ * local loopback), IPv6 `::1`, or the dotted IPv4-mapped spelling of a
10
+ * `127.0.0.0/8` address (`::ffff:127.0.0.1`). Address shapes are validated by
11
+ * `node:net`'s `isIP`, so a near-miss NAME like `127.0.0.1.evil.com` is remote.
12
+ *
13
+ * The check is purely syntactic — no DNS, no interface list — so the same
14
+ * config classifies the same on a laptop, on CI, and on a machine with no
15
+ * network. Ambiguity resolves toward LOOPBACK: misclassifying a local server
16
+ * as remote widens a concurrency pool onto a single-threaded local model
17
+ * (one loaded model, reload thrash, HTTP 500 — a hard failure); the reverse
18
+ * only costs throughput. The remedy in either direction is one line of config:
19
+ * `engines.<name>.concurrency`.
20
+ *
21
+ * The ONE local-vs-remote answer for concurrency defaults — the workflow
22
+ * engine's frozen engine concurrency (`workflows/concurrency-policy.ts`) and
23
+ * the indexer's LLM pool (`indexer/indexer.ts` `getDefaultLlmConcurrency`)
24
+ * both classify through here. NOT shared with the website snapshot fetcher's
25
+ * SSRF policy (`sources/snapshot-fetchers/host-guard.ts`), which deliberately
26
+ * draws different lines for a different threat model.
27
+ *
28
+ * @module core/loopback
29
+ */
30
+ import { isIP } from "node:net";
31
+ /**
32
+ * The IPv4-mapped IPv6 spellings of `127.0.0.0/8`: the dotted form, whose
33
+ * capture is validated by `isIP`, and the hex form `::ffff:7fxx:xxxx`. Both are
34
+ * needed because WHATWG `URL` re-serializes `[::ffff:127.0.0.1]` to the hex
35
+ * form, so an endpoint written the dotted way arrives here as hex.
36
+ */
37
+ const IPV4_MAPPED_IPV6 = /^::ffff:(?:([\d.]+)|7f[\da-f]{2}:[\da-f]{1,4})$/;
38
+ /** IPv6 groups before the last one, all of which must be zero (or elided). */
39
+ const ZERO_GROUP = /^0+$/;
40
+ /** The last IPv6 group of `::` / `::1`, in any zero-padding. */
41
+ const LOOPBACK_LAST_GROUP = /^0*[01]?$/;
42
+ /**
43
+ * True when `host` — a URL host component, with or without IPv6 brackets —
44
+ * names this machine WITHOUT resolving anything.
45
+ *
46
+ * Not recognized: the IPv4-compatible form (`::127.0.0.1`), and any NAME that
47
+ * merely resolves to loopback.
48
+ *
49
+ * Exported for the boundary-case table in
50
+ * `tests/workflows/concurrency-defaults.test.ts`, which has to reach the host
51
+ * predicate directly: several of its cases (a bare `""`, a malformed literal
52
+ * like `1::2::3`) cannot round-trip through a URL without changing meaning.
53
+ */
54
+ export function isLoopbackHost(host) {
55
+ // Strip IPv6 brackets (`URL.hostname` keeps them) and the DNS root label. An
56
+ // empty host (`new URL("localhost:1234")` parses as scheme `localhost:` with
57
+ // no host) has nothing to judge, so it fails safe like an unparseable one.
58
+ const name = host.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
59
+ if (name === "" || name === "localhost" || name.endsWith(".localhost"))
60
+ return true;
61
+ const mapped = IPV4_MAPPED_IPV6.exec(name);
62
+ if (mapped) {
63
+ const quad = mapped[1];
64
+ // The hex branch already matched 127.x in the pattern; the dotted one still
65
+ // needs `isIP` so `::ffff:127.0.0.256` stays remote.
66
+ return quad === undefined || (isIP(quad) === 4 && quad.startsWith("127."));
67
+ }
68
+ const version = isIP(name);
69
+ if (version === 4)
70
+ return name.startsWith("127.") || name === "0.0.0.0";
71
+ if (version !== 6)
72
+ return false;
73
+ const groups = name.split(":");
74
+ const last = groups.pop() ?? "";
75
+ return groups.every((group) => group === "" || ZERO_GROUP.test(group)) && LOOPBACK_LAST_GROUP.test(last);
76
+ }
77
+ /** True when `endpoint` points at this machine (see {@link isLoopbackHost}). */
78
+ export function isLoopbackEndpoint(endpoint) {
79
+ if (!endpoint)
80
+ return true;
81
+ try {
82
+ return isLoopbackHost(new URL(endpoint).hostname);
83
+ }
84
+ catch {
85
+ // An unparseable endpoint is treated as local: guessing "remote" here would
86
+ // widen the pool on exactly the configs we understand least.
87
+ return true;
88
+ }
89
+ }
@@ -2,9 +2,9 @@
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
4
  import crypto from "node:crypto";
5
- import fs from "node:fs";
6
5
  import path from "node:path";
7
6
  import { ConfigError } from "./errors.js";
7
+ import { classifyPathAccess, describeInaccessiblePath } from "./path-access.js";
8
8
  import { getConfigPath, getDataDir } from "./paths.js";
9
9
  let afterPendingCheckHook;
10
10
  /** TEST-ONLY: run once after a clear pending-operation check. */
@@ -45,12 +45,27 @@ export function getMigrationApplyJournalPath() {
45
45
  export function getMigrationGeneratedConfigPath() {
46
46
  return path.join(getMigrationOperationRoot(), "generated-config.json");
47
47
  }
48
+ /**
49
+ * Refuse canonical config/database access while a migration or restore is
50
+ * mid-flight.
51
+ *
52
+ * This is a SAFETY gate, so "I could not tell" must fail the same way "yes"
53
+ * does. `fs.existsSync` answered `false` for an unreadable journal exactly as
54
+ * for an absent one (#791), which meant a permission fault on the journal
55
+ * silently CLEARED the gate and let akm open the canonical databases on top of
56
+ * a half-applied migration. Absence is the only answer that may open the door.
57
+ */
48
58
  export function assertNoPendingMigrationOperation() {
49
59
  for (const [kind, journalPath] of [
50
60
  ["restore", getMigrationRestoreJournalPath()],
51
61
  ["migration apply", getMigrationApplyJournalPath()],
52
62
  ]) {
53
- if (fs.existsSync(journalPath)) {
63
+ const { access, code } = classifyPathAccess(journalPath);
64
+ if (access === "inaccessible") {
65
+ throw new ConfigError(`Cannot determine whether an AKM ${kind} recovery is pending: ${describeInaccessiblePath(journalPath, code)}. ` +
66
+ "Refusing canonical config/database access — proceeding could write on top of a half-applied migration.", "DATA_DIR_UNREADABLE");
67
+ }
68
+ if (access === "present") {
54
69
  throw new ConfigError(`AKM ${kind} recovery is pending at ${journalPath}; refusing canonical config/database access until recovery completes.`, "INVALID_CONFIG_FILE");
55
70
  }
56
71
  }
@@ -0,0 +1,107 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Tell "this path is not there" apart from "I am not allowed to look at it"
6
+ * (issue #791).
7
+ *
8
+ * # Why this exists
9
+ *
10
+ * `fs.existsSync()` answers `false` for BOTH cases: it swallows every error,
11
+ * so `ENOENT` (genuinely absent) and `EACCES` (present, unreadable) are
12
+ * indistinguishable. akm used `existsSync` as its "is there an index?" gate on
13
+ * the read path, so an index the caller could not read was reported as an index
14
+ * that did not exist — `akm search` and `akm curate` returned
15
+ * `hits: []` with the tip *"No search index available. Run 'akm index' to build
16
+ * one."* at exit 0, for a populated index sitting right there on disk.
17
+ *
18
+ * That is the worst possible shape for a failure. A non-zero exit is a problem
19
+ * the operator can see; an empty-but-successful result is a lie that a
20
+ * consuming agent will confidently relay to its user. In the report that
21
+ * prompted this module, exactly that happened: an agent told its user akm's
22
+ * "vector service is unavailable and its maintenance lock is read-only" — a
23
+ * story it invented to explain results that claimed to be fine.
24
+ *
25
+ * # Contract
26
+ *
27
+ * `absent` means the path (or a parent component) genuinely is not there, which
28
+ * is a legitimate first-run state every caller already handles. `inaccessible`
29
+ * means the path may well exist and this process cannot determine that or read
30
+ * it — a caller must NEVER degrade that to an empty-but-successful result.
31
+ */
32
+ import fs from "node:fs";
33
+ function errnoOf(error) {
34
+ const code = error?.code;
35
+ return typeof code === "string" ? code : undefined;
36
+ }
37
+ /**
38
+ * Classify `target` as {@link PathAccess}.
39
+ *
40
+ * `ENOENT`/`ENOTDIR` are `absent` — the path cannot exist as named. Everything
41
+ * else that fails is `inaccessible`, carrying its errno: a permission error is
42
+ * the common case, but a symlink loop or an I/O error are equally "present as
43
+ * far as anyone knows, and unusable", and silently treating them as "no index"
44
+ * is the bug this module exists to prevent.
45
+ *
46
+ * Statting is not enough — a file can be `stat`-able through a searchable
47
+ * parent while being unreadable itself — so a successful stat is confirmed with
48
+ * an `R_OK` access check.
49
+ */
50
+ export function classifyPathAccess(target) {
51
+ try {
52
+ fs.statSync(target);
53
+ }
54
+ catch (error) {
55
+ const code = errnoOf(error);
56
+ if (code === "ENOENT" || code === "ENOTDIR")
57
+ return { access: "absent" };
58
+ return { access: "inaccessible", ...(code ? { code } : {}) };
59
+ }
60
+ try {
61
+ fs.accessSync(target, fs.constants.R_OK);
62
+ }
63
+ catch (error) {
64
+ const code = errnoOf(error);
65
+ return { access: "inaccessible", ...(code ? { code } : {}) };
66
+ }
67
+ return { access: "present" };
68
+ }
69
+ /** True when the path is genuinely absent — the ordinary "not built yet" state. */
70
+ export function isPathAbsent(target) {
71
+ return classifyPathAccess(target).access === "absent";
72
+ }
73
+ /**
74
+ * A diagnostic line naming everything an operator needs to fix a permission
75
+ * problem without a second round trip: the path, the errno, the mode and owner
76
+ * of whatever akm *could* stat along the way, and the uid actually running.
77
+ *
78
+ * Deliberately best-effort — this runs on an error path, so a failure to gather
79
+ * detail must never mask the error being described.
80
+ */
81
+ export function describeInaccessiblePath(target, code) {
82
+ const parts = [target];
83
+ if (code)
84
+ parts.push(`(${code})`);
85
+ try {
86
+ const stat = fs.statSync(target);
87
+ parts.push(`mode ${(stat.mode & 0o777).toString(8).padStart(3, "0")}, owner uid ${stat.uid}`);
88
+ }
89
+ catch {
90
+ // Cannot stat the file itself — describe the closest parent we CAN see,
91
+ // which is usually where the missing permission actually is.
92
+ const parent = target.slice(0, Math.max(0, target.lastIndexOf("/")));
93
+ if (parent) {
94
+ try {
95
+ const stat = fs.statSync(parent);
96
+ parts.push(`parent ${parent} is mode ${(stat.mode & 0o777).toString(8).padStart(3, "0")}, owner uid ${stat.uid}`);
97
+ }
98
+ catch {
99
+ // Nothing further to say.
100
+ }
101
+ }
102
+ }
103
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
104
+ if (uid !== undefined)
105
+ parts.push(`running as uid ${uid}`);
106
+ return parts.join("; ");
107
+ }
@@ -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) {