akm-cli 0.9.0 → 0.9.1-beta.1

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 (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. package/schemas/akm-workflow.json +85 -10
@@ -0,0 +1,28 @@
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
+ export function armAbortDeadline(controller, options) {
5
+ const { timeoutMs, reason } = options;
6
+ if (timeoutMs === null || timeoutMs === undefined) {
7
+ return { disarm: () => { }, timedOut: () => false };
8
+ }
9
+ const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
10
+ const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
11
+ let fired = false;
12
+ let timer = setTimeoutImpl(() => {
13
+ timer = undefined;
14
+ fired = true;
15
+ controller.abort(new Error(reason));
16
+ }, timeoutMs);
17
+ // A pending deadline must never be the reason the process stays alive.
18
+ timer?.unref?.();
19
+ return {
20
+ disarm: () => {
21
+ if (timer !== undefined) {
22
+ clearTimeoutImpl(timer);
23
+ timer = undefined;
24
+ }
25
+ },
26
+ timedOut: () => fired,
27
+ };
28
+ }
@@ -27,10 +27,13 @@
27
27
  * - `skill-description-too-long` — description must be 1-1024 chars.
28
28
  * - `missing-skill-md` — a package dir with no SKILL.md (edge case; git cannot
29
29
  * commit an empty dir, so it is covered by a directory-level check, not a
30
- * fixture).
30
+ * fixture). Implemented by {@link missingManifestDiagnostics}, which scans
31
+ * the component root through `ValidateContext.list` — the change-set loop
32
+ * cannot reach it, because a change is always a file and a manifest-less
33
+ * package contributes no SKILL.md change (issue #774).
31
34
  *
32
- * Only `missing-skill-md` is coded elsewhere today; the two field codes are
33
- * APPROVED-BUT-NOT-YET-CODED and are implemented here. Base checks are NOT run:
35
+ * The two field codes are APPROVED-BUT-NOT-YET-CODED elsewhere and are
36
+ * implemented here. Base checks are NOT run:
34
37
  * a SKILL.md carries no `updated` field, so `missing-updated` would fire on
35
38
  * every conformant skill and contradict the lint golden.
36
39
  *
@@ -127,6 +130,63 @@ function skillFieldDiagnostics(relPath, dirName, data) {
127
130
  }
128
131
  return diagnostics;
129
132
  }
133
+ /**
134
+ * How deep below a candidate package directory the manifest probe looks. Agent
135
+ * Skills packages sit at the component root (`<name>/SKILL.md`), occasionally
136
+ * one group level down (`<group>/<name>/SKILL.md`) — this bound keeps a deep
137
+ * resource tree (a package's `reference/`, `assets/`, …) from turning the lint
138
+ * sweep into a full recursive walk.
139
+ */
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) {
143
+ if (entries.includes(SKILL_MANIFEST))
144
+ return true;
145
+ if (depth >= MAX_PACKAGE_PROBE_DEPTH)
146
+ return false;
147
+ for (const entry of entries) {
148
+ const child = `${dir}/${entry}`;
149
+ // `list` on a FILE yields `[]` (the read throws and is swallowed), so an
150
+ // empty listing is the "not a directory worth descending" signal — no
151
+ // separate stat is available on ValidateContext, and none is needed.
152
+ const childEntries = await ctx.list(child);
153
+ if (childEntries.length === 0)
154
+ continue;
155
+ if (await subtreeHasManifest(child, childEntries, ctx, depth + 1))
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+ /**
161
+ * The directory-level `missing-skill-md` check (issue #774).
162
+ *
163
+ * `validate` walks CHANGES, and a change is always a file — so a package
164
+ * directory carrying resources but no manifest contributes nothing the
165
+ * change-loop can see, and the check the spec (§4.5) and the lint golden's
166
+ * `missingSkillMd` edge case both name was unreachable. This scans the
167
+ * component root through {@link ValidateContext.list} instead, so the case is
168
+ * actually reported.
169
+ *
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.
175
+ */
176
+ async function missingManifestDiagnostics(ctx) {
177
+ const diagnostics = [];
178
+ for (const name of await ctx.list(".")) {
179
+ if (name.startsWith("."))
180
+ continue; // .git, .github, … are not skill packages
181
+ const entries = await ctx.list(name);
182
+ if (entries.length === 0)
183
+ 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 });
187
+ }
188
+ return diagnostics;
189
+ }
130
190
  async function validate(_c, changes, ctx) {
131
191
  const diagnostics = [];
132
192
  const seenDirs = new Set();
@@ -142,10 +202,12 @@ async function validate(_c, changes, ctx) {
142
202
  if (seenDirs.has(pkg.conceptId))
143
203
  continue;
144
204
  seenDirs.add(pkg.conceptId);
145
- // missing-skill-md is unreachable here (the change IS a SKILL.md); the empty-dir
146
- // case is served by {@link directorySkillDiagnostics} for callers that scan dirs.
205
+ // `missing-skill-md` cannot fire here the change IS a SKILL.md. The
206
+ // manifest-less package case is served by {@link missingManifestDiagnostics}
207
+ // below, which scans directories rather than changes.
147
208
  diagnostics.push(...skillFieldDiagnostics(toPosix(change.path), pkg.dirName, parseFrontmatter(raw).data));
148
209
  }
210
+ diagnostics.push(...(await missingManifestDiagnostics(ctx)));
149
211
  return diagnostics;
150
212
  }
151
213
  export const agentSkillsAdapter = {
@@ -80,8 +80,8 @@
80
80
  */
81
81
  import fs from "node:fs";
82
82
  import path from "node:path";
83
- import { parse as parseYaml } from "yaml";
84
83
  import { applyPostContributorFields, applyPreContributorFields, extractPackageMetadata, } from "../../../indexer/passes/metadata.js";
84
+ import { parseTaskYaml, taskYamlParseDetail } from "../../../tasks/schema.js";
85
85
  import { assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
86
86
  import { parseFrontmatter } from "../../asset/frontmatter.js";
87
87
  import { recognizeMatch } from "../recognize-match.js";
@@ -380,16 +380,19 @@ async function validate(c, changes, ctx) {
380
380
  // everything else → `parseFrontmatter`.
381
381
  let parsed;
382
382
  if (type === "task") {
383
- let data = {};
384
- try {
385
- const doc = parseYaml(raw);
386
- if (doc && typeof doc === "object" && !Array.isArray(doc))
387
- data = doc;
383
+ const task = parseTaskYaml(raw);
384
+ // A parse failure is its OWN finding: every task rule short-circuits on
385
+ // an empty mapping, so collapsing "unparseable" onto `{}` made a broken
386
+ // task file validate clean (issue #760). Mirrors the CLI sweep.
387
+ if (!task.ok) {
388
+ diagnostics.push({
389
+ file: change.path,
390
+ issue: "invalid-task-yaml",
391
+ detail: taskYamlParseDetail(task.error),
392
+ fixed: false,
393
+ });
388
394
  }
389
- catch {
390
- data = {};
391
- }
392
- parsed = { data, content: raw, frontmatter: null };
395
+ parsed = { data: task.data, content: raw, frontmatter: null };
393
396
  }
394
397
  else {
395
398
  const p = parseFrontmatter(raw);
@@ -187,20 +187,29 @@ export function dangerousEnvKeyDiagnostics(type, relPath, raw) {
187
187
  return diagnostics;
188
188
  }
189
189
  // ── skill directory check (SkillLinter.lintDirectory) ────────────────────────
190
+ /** The akm-native skill placement dir — the default gate for {@link skillDirectoryDiagnostics}. */
191
+ const AKM_SKILL_DIRS = new Set(["skills"]);
190
192
  /**
191
193
  * Reproduce `SkillLinter.lintDirectory` (`skill-linter.ts:31-45`) in the
192
- * change-set model: for a change under `skills/<name>/…`, emit `missing-skill-md`
193
- * when `skills/<name>/SKILL.md` is absent from the overlay. `seen` dedups so a
194
- * dir with multiple changed files reports once (matching the per-subdir call).
195
- * `file`/`detail` mirror the live check exactly (relDir + `no SKILL.md in <relDir>/`).
194
+ * change-set model: for a change under `<skillDir>/<name>/…`, emit
195
+ * `missing-skill-md` when `<skillDir>/<name>/SKILL.md` is absent from the
196
+ * overlay. `seen` dedups so a dir with multiple changed files reports once
197
+ * (matching the per-subdir call). `file`/`detail` mirror the live check exactly
198
+ * (relDir + `no SKILL.md in <relDir>/`).
199
+ *
200
+ * `skillDirs` defaults to the akm-native `skills/` placement dir. The tool-dir
201
+ * adapters pass their OWN accepted spellings, because opencode also accepts the
202
+ * singular `skill/` alias on read (`opencode-adapter.ts` LAYOUT) — with the
203
+ * gate hardcoded to `"skills"`, an identical manifest-less package went flagged
204
+ * under `skills/` and unflagged under `skill/` (issue #774).
196
205
  */
197
- export async function skillDirectoryDiagnostics(relPath, seen, ctx) {
206
+ export async function skillDirectoryDiagnostics(relPath, seen, ctx, skillDirs = AKM_SKILL_DIRS) {
198
207
  const segments = relPath
199
208
  .replace(/\\/g, "/")
200
209
  .split("/")
201
210
  .filter((s) => s.length > 0);
202
- if (segments.length < 3 || segments[0] !== "skills")
203
- return []; // must be skills/<name>/<file…>
211
+ if (segments.length < 3 || !skillDirs.has(segments[0]))
212
+ return []; // must be <skillDir>/<name>/<file…>
204
213
  const skillDir = `${segments[0]}/${segments[1]}`;
205
214
  if (seen.has(skillDir))
206
215
  return [];
@@ -300,51 +309,94 @@ export function matchWorkflowPlaceholder(body) {
300
309
  }
301
310
  /**
302
311
  * WorkflowLinter's `invalid-workflow-structure` check (`workflow-linter.ts:48-77`):
303
- * parse and compile through the unified workflow frontend, surfacing every
304
- * structural or semantic error and skipping the read-only `/.cache/`+`/registry/`
305
- * cached copies. Shared with the live linter;
306
- * `parsePath` is the path handed to `parseWorkflow` (the adapter passes the
307
- * change relPath, the CLI passes the absolute filePath — matching each caller's
308
- * legacy behavior). NEVER writes.
312
+ * the ERROR half of {@link workflowFrontendDiagnostics}, for callers that only
313
+ * ever surface fatal findings the read-only adapter `validate` path and
314
+ * `akm migrate`'s stale-workflow probe. A caller that ALSO surfaces the
315
+ * advisories must call {@link workflowFrontendDiagnostics} once instead of
316
+ * pairing this with a second view. NEVER writes.
309
317
  */
310
318
  export function workflowStructureDiagnostics(relPath, raw, parsePath) {
319
+ return workflowFrontendDiagnostics(relPath, raw, parsePath).errors;
320
+ }
321
+ /**
322
+ * The `Diagnostic.line` fragment for a line-anchored workflow finding. Every
323
+ * `WorkflowError` carries a 1-indexed `line`; this used to be DROPPED here, so
324
+ * an author linting a 300-line workflow got a message with no location while
325
+ * the same error rendered as `path:line — message` on the `workflow create`
326
+ * path. Spread (`...lineOf(err)`) rather than assigned, so a nonsense line
327
+ * never materializes the optional key on a whole-file finding.
328
+ */
329
+ function lineOf(err) {
330
+ return typeof err.line === "number" && Number.isFinite(err.line) && err.line > 0 ? { line: err.line } : {};
331
+ }
332
+ /**
333
+ * ONE parse+compile of a workflow through the unified frontend, returning both
334
+ * halves of what it produces: fatal `invalid-workflow-structure` findings, and
335
+ * `compileWorkflowPlan`'s non-fatal `workflow-warning` advisories (a step with
336
+ * no `output:` schema, a reference to an undeclared param). The read-only
337
+ * `/.cache/` + `/registry/` cached copies are skipped, and nothing is written.
338
+ *
339
+ * `parsePath` is the path handed to `parseWorkflow` (the adapter passes the
340
+ * change relPath, the CLI passes the absolute filePath — matching each
341
+ * caller's legacy behavior).
342
+ *
343
+ * A caller that surfaces BOTH halves must call this once and route the result
344
+ * itself. The frontend is expensive — instruction bodies reach
345
+ * `WORKFLOW_MAX_INSTRUCTION_BYTES` — so asking for each half through its own
346
+ * view parses and compiles every workflow in the stash twice.
347
+ */
348
+ export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
349
+ const none = { errors: [], warnings: [] };
311
350
  if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
312
- return [];
313
- const diagnostics = [];
351
+ return none;
352
+ const errors = [];
353
+ const warnings = [];
314
354
  try {
315
355
  const result = parseWorkflow(raw, { path: parsePath });
316
356
  if (!result.ok) {
317
357
  for (const err of result.errors ?? []) {
318
- diagnostics.push({
358
+ errors.push({
319
359
  file: relPath,
320
360
  issue: "invalid-workflow-structure",
321
361
  detail: err.message ?? String(err),
322
362
  fixed: false,
363
+ ...lineOf(err),
323
364
  });
324
365
  }
325
- return diagnostics;
366
+ return { errors, warnings };
326
367
  }
327
368
  const compiled = compileWorkflowPlan(result.document, path.basename(parsePath, path.extname(parsePath)));
328
369
  if (!compiled.ok) {
329
370
  for (const err of compiled.errors) {
330
- diagnostics.push({
371
+ errors.push({
331
372
  file: relPath,
332
373
  issue: "invalid-workflow-structure",
333
374
  detail: err.message,
334
375
  fixed: false,
376
+ ...lineOf(err),
335
377
  });
336
378
  }
379
+ return { errors, warnings };
380
+ }
381
+ for (const warning of compiled.warnings) {
382
+ warnings.push({
383
+ file: relPath,
384
+ issue: "workflow-warning",
385
+ detail: warning.message,
386
+ fixed: false,
387
+ ...lineOf(warning),
388
+ });
337
389
  }
338
390
  }
339
391
  catch (e) {
340
- diagnostics.push({
392
+ errors.push({
341
393
  file: relPath,
342
394
  issue: "invalid-workflow-structure",
343
395
  detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
344
396
  fixed: false,
345
397
  });
346
398
  }
347
- return diagnostics;
399
+ return { errors, warnings };
348
400
  }
349
401
  /**
350
402
  * WorkflowLinter extra checks (`workflow-linter.ts:22-79`), READ-ONLY:
@@ -10,6 +10,10 @@
10
10
  * invalid task (e.g. two targets) is still RECOGNIZED; the `invalid-task-yaml`
11
11
  * violation surfaces only in `validate`.
12
12
  *
13
+ * `.yaml` is the one extension `validate` inspects but `recognize` refuses: it
14
+ * is not a task spelling (nothing indexes or schedules it), so it is reported
15
+ * as `invalid-task-yaml` rather than silently skipped (issue #760).
16
+ *
13
17
  * ── validate (spec §6 task validation column) ──
14
18
  *
15
19
  * A task must declare `version: 2`, a `schedule`, and EXACTLY ONE target
@@ -26,13 +30,12 @@
26
30
  */
27
31
  import fs from "node:fs";
28
32
  import path from "node:path";
29
- import { parse as parseYaml } from "yaml";
30
- import { taskFieldProblems } from "../../../tasks/schema.js";
33
+ import { parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
31
34
  import { hashContent } from "./shared.js";
32
35
  /** A native task bundle is single-component; its one component is `main`. */
33
36
  const COMPONENT_ID = "main";
34
37
  /** The task YAML extension (spec §6 task row). */
35
- const TASK_EXT = ".yml";
38
+ const TASK_EXT = TASK_EXTENSION;
36
39
  /** The mutually-exclusive task target keys (exactly one required). */
37
40
  const TARGET_KEYS = ["prompt", "workflow", "command"];
38
41
  /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
@@ -59,18 +62,6 @@ function recognize(c, file) {
59
62
  content: raw.length > MAX_CONTENT_CHARS ? raw.slice(0, MAX_CONTENT_CHARS) : raw,
60
63
  };
61
64
  }
62
- /** Parse a task YAML into a plain record (tolerant: malformed / non-mapping → {}). */
63
- function parseTaskYaml(raw) {
64
- try {
65
- const doc = parseYaml(raw);
66
- if (doc && typeof doc === "object" && !Array.isArray(doc))
67
- return doc;
68
- }
69
- catch {
70
- // malformed YAML
71
- }
72
- return {};
73
- }
74
65
  /**
75
66
  * The native `invalid-task-yaml` check: the shared field rules
76
67
  * ({@link taskFieldProblems} — see its doc for the lint-vs-parser
@@ -99,16 +90,46 @@ async function validate(_c, changes, ctx) {
99
90
  const raw = change.after ?? (await ctx.readFile(change.path));
100
91
  if (typeof raw !== "string")
101
92
  continue;
102
- if (path.extname(change.path).toLowerCase() !== TASK_EXT)
93
+ const ext = path.extname(change.path).toLowerCase();
94
+ // `.yaml` is NOT a task extension — the file never indexes and never runs.
95
+ // It is validated here purely so the near miss is REPORTED rather than
96
+ // skipped the way every other extension is (issue #760).
97
+ if (ext !== TASK_EXT && ext !== TASK_NEAR_MISS_EXTENSION)
103
98
  continue;
104
- diagnostics.push(...taskDiagnostics(toPosix(change.path), parseTaskYaml(raw)));
99
+ const relPath = toPosix(change.path);
100
+ if (ext === TASK_NEAR_MISS_EXTENSION) {
101
+ diagnostics.push({
102
+ file: relPath,
103
+ issue: "invalid-task-yaml",
104
+ detail: taskExtensionDetail(relPath),
105
+ fixed: false,
106
+ });
107
+ }
108
+ const parsed = parseTaskYaml(raw);
109
+ if (!parsed.ok) {
110
+ // Distinguish "unparseable" from "empty": `taskDiagnostics` returns []
111
+ // for an empty mapping, so collapsing a parse failure onto `{}` made a
112
+ // broken task file lint clean.
113
+ diagnostics.push({
114
+ file: relPath,
115
+ issue: "invalid-task-yaml",
116
+ detail: taskYamlParseDetail(parsed.error),
117
+ fixed: false,
118
+ });
119
+ continue;
120
+ }
121
+ diagnostics.push(...taskDiagnostics(relPath, parsed.data));
105
122
  }
106
123
  return diagnostics;
107
124
  }
108
125
  export const akmTaskAdapter = {
109
126
  id: "akm-task",
110
127
  version: "0.9.0",
111
- extensions: [TASK_EXT],
128
+ // `.yaml` is listed as a COLLECTION hint only — `recognize` still gates on
129
+ // `.yml`, so a `.yaml` file is never indexed as a task. Listing it is what
130
+ // routes the near-miss file into `validate`, where it is reported instead of
131
+ // silently skipped (issue #760).
132
+ extensions: [TASK_EXT, TASK_NEAR_MISS_EXTENSION],
112
133
  recognize,
113
134
  validate,
114
135
  /** A task places to `<conceptId>.yml`; an already-suffixed conceptId is idempotent. */
@@ -143,7 +164,7 @@ export const akmTaskAdapter = {
143
164
  catch {
144
165
  continue;
145
166
  }
146
- const data = parseTaskYaml(raw);
167
+ const { data } = parseTaskYaml(raw);
147
168
  if (typeof data.schedule === "string" && data.schedule.trim() !== "")
148
169
  return true;
149
170
  }
@@ -185,9 +185,11 @@ export async function validateToolDir(layout, c, changes, ctx) {
185
185
  continue;
186
186
  const relPath = toPosix(change.path);
187
187
  // The one coded skill check (missing-skill-md) fires on ANY change under a
188
- // `skills/<name>/…` package (self-gated + deduped), even a bundled resource —
189
- // mirrors the akm adapter's per-change SkillLinter.lintDirectory pass.
190
- diagnostics.push(...(await skillDirectoryDiagnostics(relPath, seenSkillDirs, ctx)));
188
+ // `<skillDir>/<name>/…` package (self-gated + deduped), even a bundled
189
+ // resource — mirrors the akm adapter's per-change SkillLinter.lintDirectory
190
+ // pass. `layout.skillDirs` is passed so opencode's singular `skill/` alias
191
+ // is checked identically to `skills/` (issue #774).
192
+ diagnostics.push(...(await skillDirectoryDiagnostics(relPath, seenSkillDirs, ctx, layout.skillDirs)));
191
193
  const cls = classify(change.path, layout);
192
194
  if (cls === null)
193
195
  continue;
@@ -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 { recordWrittenPath } from "../write-provenance.js";
13
14
  import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
14
15
  /**
15
16
  * Parse YAML frontmatter from a Markdown (or similar) string.
@@ -143,6 +144,9 @@ export function mutateFrontmatter(filePath, mutator) {
143
144
  ? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
144
145
  : assembleAsset(nextFrontmatter, parsed.content);
145
146
  fs.writeFileSync(filePath, next, "utf8");
147
+ // #652: in-place frontmatter stamps (belief state, contradiction markers,
148
+ // salience) are real asset mutations — journal them for the run's sync.
149
+ recordWrittenPath(filePath);
146
150
  return true;
147
151
  }
148
152
  export function parseFrontmatterBlock(raw) {
@@ -292,13 +292,49 @@ export function hasErrnoCode(error, code) {
292
292
  return false;
293
293
  return error.code === code;
294
294
  }
295
+ /**
296
+ * True when `value` is a RELATIVE path that cannot leave its base directory:
297
+ * no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
298
+ * home expansion, and no `..` segment under either separator.
299
+ *
300
+ * This is the SYNTACTIC half of containment — cheap, string-only, usable at
301
+ * authoring time before any directory exists. It is deliberately paired with
302
+ * (never a substitute for) {@link isWithin}, which resolves symlinks against a
303
+ * real base at use time. Workflow `exec` units run both: the parser and the
304
+ * frozen-plan decoder reject uncontained spellings, and the executor re-checks
305
+ * the resolved path before spawning.
306
+ */
307
+ export function isContainedRelativePath(value) {
308
+ if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
309
+ return false;
310
+ if (/^[A-Za-z]:/.test(value))
311
+ return false;
312
+ return !value.split(/[/\\]+/).includes("..");
313
+ }
295
314
  export function isWithin(candidate, root) {
296
- const resolvedRoot = safeRealpath(root);
297
- const resolvedCandidate = safeRealpath(candidate);
298
- const normalizedRoot = normalizeFsPathForComparison(resolvedRoot);
299
- const normalizedCandidate = normalizeFsPathForComparison(resolvedCandidate);
300
- const rel = path.relative(normalizedRoot, normalizedCandidate);
301
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
315
+ return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
316
+ }
317
+ /**
318
+ * {@link isWithin} for callers that must not block the event loop (e.g. the
319
+ * workflow exec dispatch path, which runs once per fan-out unit). Same
320
+ * comparison, same normalization, same nearest-existing-ancestor fallback —
321
+ * only the realpath syscalls are awaited.
322
+ */
323
+ export async function isWithinAsync(candidate, root) {
324
+ return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root));
325
+ }
326
+ /** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */
327
+ function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
328
+ const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate));
329
+ if (rel === "")
330
+ return true;
331
+ if (path.isAbsolute(rel))
332
+ return false;
333
+ // Compare the first SEGMENT, not a string prefix: `..data` and `...v2` are
334
+ // legal directory names, and only a leading `..` segment means the candidate
335
+ // climbed out of the root. Both separators, because `path.relative` answers
336
+ // in the host's spelling while callers may hold either.
337
+ return rel.split(/[/\\]+/)[0] !== "..";
302
338
  }
303
339
  /**
304
340
  * Resolve symlinks on `p`, walking up to the closest existing ancestor when
@@ -335,6 +371,30 @@ export function safeRealpath(p) {
335
371
  }
336
372
  }
337
373
  }
374
+ /** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */
375
+ export async function safeRealpathAsync(p) {
376
+ const resolved = path.resolve(p);
377
+ try {
378
+ return await fs.promises.realpath(resolved);
379
+ }
380
+ catch {
381
+ const suffix = [];
382
+ let current = resolved;
383
+ for (;;) {
384
+ const parent = path.dirname(current);
385
+ if (parent === current)
386
+ return resolved;
387
+ suffix.unshift(path.basename(current));
388
+ current = parent;
389
+ try {
390
+ return path.join(await fs.promises.realpath(current), ...suffix);
391
+ }
392
+ catch {
393
+ // parent also doesn't exist; keep walking up
394
+ }
395
+ }
396
+ }
397
+ }
338
398
  function normalizeFsPathForComparison(value) {
339
399
  return process.platform === "win32" ? value.toLowerCase() : value;
340
400
  }
@@ -1,6 +1,32 @@
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
+ /**
5
+ * Serialize `fn` behind every task previously enqueued under `key`: an
6
+ * in-process keyed promise chain (Bun is single-threaded, so this is a
7
+ * sufficient — and free — admission control for per-key mutual exclusion).
8
+ *
9
+ * `chains` is the caller's own module-state map, so independent subsystems
10
+ * (the unit-writer's per-database write queue, the worktree module's per-repo
11
+ * git lock) never share chains. A failed task rejects its OWN caller but
12
+ * never wedges the chain, and a drained tail deletes its map entry so a
13
+ * long-lived process does not retain one settled promise per key it ever
14
+ * touched.
15
+ */
16
+ export function serializeByKey(chains, key, fn) {
17
+ const tail = chains.get(key) ?? Promise.resolve();
18
+ const run = tail.then(() => fn());
19
+ // Keep the chain alive regardless of individual outcomes.
20
+ const settled = run.then(() => undefined, () => undefined);
21
+ chains.set(key, settled);
22
+ // If another task was enqueued in the meantime the map now holds ITS tail,
23
+ // and this check leaves it alone.
24
+ void settled.then(() => {
25
+ if (chains.get(key) === settled)
26
+ chains.delete(key);
27
+ });
28
+ return run;
29
+ }
4
30
  /**
5
31
  * Maps over items concurrently with a pool size limit.
6
32
  * Uses Promise.allSettled semantics — one failure does not cancel others.
@@ -11,6 +37,12 @@
11
37
  * preempt those too). Unclaimed items stay `undefined` in the result,
12
38
  * indistinguishable from individual failures by design: callers already
13
39
  * treat `undefined` as "no result".
40
+ *
41
+ * A thrown `fn` is SWALLOWED (its slot stays `undefined`) — a caller that
42
+ * must report failure detail, or distinguish "threw" from "never claimed",
43
+ * catches inside `fn` and returns an explicit outcome value instead. This is
44
+ * the OPPOSITE of {@link serializeByKey} above, whose failures reject their
45
+ * own caller.
14
46
  */
15
47
  export async function concurrentMap(items, fn, concurrency = 1, opts) {
16
48
  const results = new Array(items.length).fill(undefined);
@@ -19,10 +19,21 @@ import { engineName, positiveInt } from "./primitives.js";
19
19
  * `[1, WORKFLOW_MAX_CONCURRENCY_CEILING]` (64). Values above the ceiling
20
20
  * are clamped, not rejected, so a config shared across machines with wildly
21
21
  * different core counts never hard-fails validation.
22
+ *
23
+ * `defaultMapConcurrency` is the width a `map` step freezes when it declares no
24
+ * `concurrency:` of its own:
25
+ * - UNSET → `DEFAULT_MAP_CONCURRENCY` (4) from
26
+ * `src/workflows/concurrency-policy.ts`.
27
+ * - SET → the explicit positive integer, CLAMPED to `[1, 64]`. Setting it
28
+ * to `1` restores the pre-0.9.1 serial-by-default fan-out for every
29
+ * workflow on this install. It is a floor for authoring only: it never
30
+ * raises a step above `maxConcurrency`, the engine's concurrency, or the
31
+ * host CPU cap, and it never overrides an authored `map.concurrency`.
22
32
  */
23
33
  export const WorkflowConfigSchema = z
24
34
  .object({
25
35
  maxConcurrency: positiveInt.optional(),
36
+ defaultMapConcurrency: positiveInt.optional(),
26
37
  /** Named LLM or agent engine frozen into every criteria-bearing gate. */
27
38
  judgeEngine: engineName.optional(),
28
39
  })
@@ -9,6 +9,7 @@ const CONFIG_HINTS = {
9
9
  STASH_DIR_NOT_FOUND: "Run `akm setup` to create and configure your bundle, or configure a defaultBundle path.",
10
10
  STASH_DIR_NOT_A_DIRECTORY: "The configured default bundle path exists but isn't a directory. Update it to point at a folder.",
11
11
  STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update the default bundle path.",
12
+ DATA_DIR_UNREADABLE: "The data directory is not readable by the user running akm. Check its owner and mode, or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns.",
12
13
  EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
13
14
  LLM_NOT_CONFIGURED: 'Run `akm setup` or configure an `engines` entry with `kind: "llm"`, then select it with `defaults.llmEngine`.',
14
15
  TEST_ISOLATION_MISSING: "Under bun test, when AKM_BUNDLE_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
@@ -131,3 +132,27 @@ export function rethrowIfTestIsolationError(err) {
131
132
  throw err;
132
133
  }
133
134
  }
135
+ /**
136
+ * Unreadable-data-dir guard helper — the #791 sibling of the test-isolation
137
+ * pair above, and it exists for the same reason.
138
+ *
139
+ * `DATA_DIR_UNREADABLE` says "this path is there and I am not allowed to read
140
+ * it". It is raised by `assertIndexPathReadable` and friends precisely so a
141
+ * permission fault stops being indistinguishable from "nothing indexed yet".
142
+ * That distinction is destroyed again the moment a best-effort `catch` around
143
+ * the open collapses it into the same `null`/`[]`/`0` the absent case returns —
144
+ * which is how `akm search` came to answer "No search index available. Run
145
+ * 'akm index'" at exit 0 for a populated index sitting right there on disk.
146
+ *
147
+ * Call `rethrowIfDataDirUnreadable(err)` from any catch block that returns a
148
+ * fallback value after touching a data-dir path. Absent stays absent; a fault
149
+ * the operator has to fix keeps travelling.
150
+ */
151
+ export function isDataDirUnreadableError(err) {
152
+ return err instanceof ConfigError && err.code === "DATA_DIR_UNREADABLE";
153
+ }
154
+ export function rethrowIfDataDirUnreadable(err) {
155
+ if (isDataDirUnreadableError(err)) {
156
+ throw err;
157
+ }
158
+ }