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
@@ -40,8 +40,12 @@ export async function akmTasksAdd(input, deps = {}) {
40
40
  if (targetCount !== 1) {
41
41
  throw new UsageError("Pass exactly one of --workflow <ref>, --prompt <asset-ref|./file.md|text>, or --command <shell-command>.", "INVALID_FLAG_VALUE");
42
42
  }
43
- if (input.workflow && (input.engine !== undefined || input.model !== undefined || input.timeoutMs !== undefined)) {
44
- throw new UsageError("Workflow tasks accept only --params; engine, model, and timeout are prompt-task fields.", "INVALID_FLAG_VALUE");
43
+ // `--timeout-ms` IS valid on a workflow task: it is the whole-run bound the
44
+ // task runner turns into an abort signal (issue 11), the same one
45
+ // `akm workflow run --timeout` applies interactively. Engine and model stay
46
+ // prompt-only — a workflow's engines come from its frozen plan.
47
+ if (input.workflow && (input.engine !== undefined || input.model !== undefined)) {
48
+ throw new UsageError("Workflow tasks accept --params and --timeout-ms; engine and model are prompt-task fields.", "INVALID_FLAG_VALUE");
45
49
  }
46
50
  if (hasCommand && (input.engine !== undefined || input.model !== undefined)) {
47
51
  throw new UsageError("Command tasks accept --timeout-ms but not --engine or --model.", "INVALID_FLAG_VALUE");
@@ -597,6 +601,8 @@ function renderTaskYaml(input) {
597
601
  if (input.params) {
598
602
  obj.params = parseJsonObjectArg(input.params);
599
603
  }
604
+ if (input.timeoutMs !== undefined)
605
+ obj.timeoutMs = input.timeoutMs;
600
606
  }
601
607
  else if (input.prompt) {
602
608
  obj.prompt = input.prompt;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { getStringArg } from "../cli/parse-args.js";
11
11
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
12
+ import { armAbortDeadline } from "../core/abort-deadline.js";
12
13
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
13
14
  import { NotFoundError, UsageError } from "../core/errors.js";
14
15
  import { akmIndex } from "../indexer/indexer.js";
@@ -152,7 +153,6 @@ const workflowRunCommand = defineJsonCommand({
152
153
  const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0, WORKFLOW_MAX_RETRIES);
153
154
  const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
154
155
  const controller = new AbortController();
155
- let timedOut = false;
156
156
  let signalExitCode;
157
157
  const interrupt = (signal) => {
158
158
  signalExitCode = signal === "SIGINT" ? 130 : 143;
@@ -162,13 +162,12 @@ const workflowRunCommand = defineJsonCommand({
162
162
  const onSigterm = () => interrupt("SIGTERM");
163
163
  process.once("SIGINT", onSigint);
164
164
  process.once("SIGTERM", onSigterm);
165
- const timer = timeoutMs === undefined
166
- ? undefined
167
- : setTimeout(() => {
168
- timedOut = true;
169
- controller.abort(new Error(`Workflow run timed out after ${timeoutMs}ms.`));
170
- }, timeoutMs);
171
- timer?.unref?.();
165
+ // The same deadline a scheduled workflow task arms (`tasks/runner.ts`),
166
+ // sharing this controller with the signal handlers above.
167
+ const deadline = armAbortDeadline(controller, {
168
+ timeoutMs,
169
+ reason: `Workflow run timed out after ${timeoutMs}ms.`,
170
+ });
172
171
  try {
173
172
  const result = await runWorkflowSteps({
174
173
  target: args.target,
@@ -177,15 +176,22 @@ const workflowRunCommand = defineJsonCommand({
177
176
  ...(maxRetries !== undefined ? { maxRetries } : {}),
178
177
  signal: controller.signal,
179
178
  });
179
+ // The abort is observed between steps, so a deadline landing in the run's
180
+ // final bookkeeping fires on a run that then finishes. Reporting that as
181
+ // timed out would send an operator to resume a run with nothing left to
182
+ // resume — `tasks/runner.ts` suppresses the same case.
183
+ const timedOut = deadline.timedOut() && result.run.status !== "completed";
180
184
  const rendered = { ...result, ...(timedOut ? { timedOut: true } : {}) };
181
185
  output("workflow-run", rendered);
182
- if (result.run.status === "failed" || result.gateRejection || result.aborted) {
186
+ // `blocked` is a stopped, unverified run a verification-judge failure
187
+ // leaves it there for `akm workflow resume` — so it must not exit 0 and
188
+ // read as success to a script (it maps to 1 for scheduled tasks too).
189
+ if (result.run.status === "failed" || result.run.status === "blocked" || result.gateRejection || result.aborted) {
183
190
  process.exitCode = signalExitCode ?? EXIT_CODES.GENERAL;
184
191
  }
185
192
  }
186
193
  finally {
187
- if (timer)
188
- clearTimeout(timer);
194
+ deadline.disarm();
189
195
  process.off("SIGINT", onSigint);
190
196
  process.off("SIGTERM", onSigterm);
191
197
  }
@@ -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,79 @@ 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
+ 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) {
151
+ if (entries.includes(SKILL_MANIFEST))
152
+ return { containsManifest: true, diagnostics: [] };
153
+ if (depth >= MAX_PACKAGE_PROBE_DEPTH) {
154
+ return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
155
+ }
156
+ const children = [];
157
+ for (const entry of entries) {
158
+ if (entry.startsWith("."))
159
+ continue;
160
+ const child = `${dir}/${entry}`;
161
+ // `list` on a FILE yields `[]` (the read throws and is swallowed), so an
162
+ // empty listing is the "not a directory worth descending" signal — no
163
+ // separate stat is available on ValidateContext, and none is needed.
164
+ const childEntries = await ctx.list(child);
165
+ if (childEntries.length === 0)
166
+ continue;
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
+ };
174
+ }
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)] };
178
+ }
179
+ /**
180
+ * The directory-level `missing-skill-md` check (issue #774).
181
+ *
182
+ * `validate` walks CHANGES, and a change is always a file — so a package
183
+ * directory carrying resources but no manifest contributes nothing the
184
+ * change-loop can see, and the check the spec (§4.5) and the lint golden's
185
+ * `missingSkillMd` edge case both name was unreachable. This scans the
186
+ * component root through {@link ValidateContext.list} instead, so the case is
187
+ * actually reported.
188
+ *
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.
193
+ */
194
+ async function missingManifestDiagnostics(ctx) {
195
+ const diagnostics = [];
196
+ for (const name of await ctx.list(".")) {
197
+ if (name.startsWith("."))
198
+ continue; // .git, .github, … are not skill packages
199
+ const entries = await ctx.list(name);
200
+ if (entries.length === 0)
201
+ continue; // a root file (README.md), or an untrackable empty dir
202
+ diagnostics.push(...(await scanPackageCandidate(name, entries, ctx, 1)).diagnostics);
203
+ }
204
+ return diagnostics;
205
+ }
130
206
  async function validate(_c, changes, ctx) {
131
207
  const diagnostics = [];
132
208
  const seenDirs = new Set();
@@ -142,10 +218,12 @@ async function validate(_c, changes, ctx) {
142
218
  if (seenDirs.has(pkg.conceptId))
143
219
  continue;
144
220
  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.
221
+ // `missing-skill-md` cannot fire here the change IS a SKILL.md. The
222
+ // manifest-less package case is served by {@link missingManifestDiagnostics}
223
+ // below, which scans directories rather than changes.
147
224
  diagnostics.push(...skillFieldDiagnostics(toPosix(change.path), pkg.dirName, parseFrontmatter(raw).data));
148
225
  }
226
+ diagnostics.push(...(await missingManifestDiagnostics(ctx)));
149
227
  return diagnostics;
150
228
  }
151
229
  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);
@@ -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";
@@ -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 [];
@@ -274,7 +283,11 @@ export function taskDiagnostics(relPath, data) {
274
283
  if (data === null || Object.keys(data).length === 0)
275
284
  return [];
276
285
  const missing = taskFieldProblems(data);
277
- 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]));
278
291
  if (!hasTarget)
279
292
  missing.push("prompt, workflow, or command");
280
293
  if (missing.length > 0) {
@@ -300,51 +313,94 @@ export function matchWorkflowPlaceholder(body) {
300
313
  }
301
314
  /**
302
315
  * 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.
316
+ * the ERROR half of {@link workflowFrontendDiagnostics}, for callers that only
317
+ * ever surface fatal findings the read-only adapter `validate` path and
318
+ * `akm migrate`'s stale-workflow probe. A caller that ALSO surfaces the
319
+ * advisories must call {@link workflowFrontendDiagnostics} once instead of
320
+ * pairing this with a second view. NEVER writes.
309
321
  */
310
322
  export function workflowStructureDiagnostics(relPath, raw, parsePath) {
323
+ return workflowFrontendDiagnostics(relPath, raw, parsePath).errors;
324
+ }
325
+ /**
326
+ * The `Diagnostic.line` fragment for a line-anchored workflow finding. Every
327
+ * `WorkflowError` carries a 1-indexed `line`; this used to be DROPPED here, so
328
+ * an author linting a 300-line workflow got a message with no location while
329
+ * the same error rendered as `path:line — message` on the `workflow create`
330
+ * path. Spread (`...lineOf(err)`) rather than assigned, so a nonsense line
331
+ * never materializes the optional key on a whole-file finding.
332
+ */
333
+ function lineOf(err) {
334
+ return typeof err.line === "number" && Number.isFinite(err.line) && err.line > 0 ? { line: err.line } : {};
335
+ }
336
+ /**
337
+ * ONE parse+compile of a workflow through the unified frontend, returning both
338
+ * halves of what it produces: fatal `invalid-workflow-structure` findings, and
339
+ * `compileWorkflowPlan`'s non-fatal `workflow-warning` advisories (a step with
340
+ * no `output:` schema, a reference to an undeclared param). The read-only
341
+ * `/.cache/` + `/registry/` cached copies are skipped, and nothing is written.
342
+ *
343
+ * `parsePath` is the path handed to `parseWorkflow` (the adapter passes the
344
+ * change relPath, the CLI passes the absolute filePath — matching each
345
+ * caller's legacy behavior).
346
+ *
347
+ * A caller that surfaces BOTH halves must call this once and route the result
348
+ * itself. The frontend is expensive — instruction bodies reach
349
+ * `WORKFLOW_MAX_INSTRUCTION_BYTES` — so asking for each half through its own
350
+ * view parses and compiles every workflow in the stash twice.
351
+ */
352
+ export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
353
+ const none = { errors: [], warnings: [] };
311
354
  if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
312
- return [];
313
- const diagnostics = [];
355
+ return none;
356
+ const errors = [];
357
+ const warnings = [];
314
358
  try {
315
359
  const result = parseWorkflow(raw, { path: parsePath });
316
360
  if (!result.ok) {
317
361
  for (const err of result.errors ?? []) {
318
- diagnostics.push({
362
+ errors.push({
319
363
  file: relPath,
320
364
  issue: "invalid-workflow-structure",
321
365
  detail: err.message ?? String(err),
322
366
  fixed: false,
367
+ ...lineOf(err),
323
368
  });
324
369
  }
325
- return diagnostics;
370
+ return { errors, warnings };
326
371
  }
327
372
  const compiled = compileWorkflowPlan(result.document, path.basename(parsePath, path.extname(parsePath)));
328
373
  if (!compiled.ok) {
329
374
  for (const err of compiled.errors) {
330
- diagnostics.push({
375
+ errors.push({
331
376
  file: relPath,
332
377
  issue: "invalid-workflow-structure",
333
378
  detail: err.message,
334
379
  fixed: false,
380
+ ...lineOf(err),
335
381
  });
336
382
  }
383
+ return { errors, warnings };
384
+ }
385
+ for (const warning of compiled.warnings) {
386
+ warnings.push({
387
+ file: relPath,
388
+ issue: "workflow-warning",
389
+ detail: warning.message,
390
+ fixed: false,
391
+ ...lineOf(warning),
392
+ });
337
393
  }
338
394
  }
339
395
  catch (e) {
340
- diagnostics.push({
396
+ errors.push({
341
397
  file: relPath,
342
398
  issue: "invalid-workflow-structure",
343
399
  detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
344
400
  fixed: false,
345
401
  });
346
402
  }
347
- return diagnostics;
403
+ return { errors, warnings };
348
404
  }
349
405
  /**
350
406
  * 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 { isPresentTarget, 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
@@ -80,7 +71,9 @@ function taskDiagnostics(relPath, data) {
80
71
  if (Object.keys(data).length === 0)
81
72
  return [];
82
73
  const problems = taskFieldProblems(data);
83
- 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]));
84
77
  if (targets.length === 0)
85
78
  problems.push("exactly one target (prompt, workflow, or command)");
86
79
  else if (targets.length > 1)
@@ -99,16 +92,46 @@ async function validate(_c, changes, ctx) {
99
92
  const raw = change.after ?? (await ctx.readFile(change.path));
100
93
  if (typeof raw !== "string")
101
94
  continue;
102
- if (path.extname(change.path).toLowerCase() !== TASK_EXT)
95
+ const ext = path.extname(change.path).toLowerCase();
96
+ // `.yaml` is NOT a task extension — the file never indexes and never runs.
97
+ // It is validated here purely so the near miss is REPORTED rather than
98
+ // skipped the way every other extension is (issue #760).
99
+ if (ext !== TASK_EXT && ext !== TASK_NEAR_MISS_EXTENSION)
103
100
  continue;
104
- diagnostics.push(...taskDiagnostics(toPosix(change.path), parseTaskYaml(raw)));
101
+ const relPath = toPosix(change.path);
102
+ if (ext === TASK_NEAR_MISS_EXTENSION) {
103
+ diagnostics.push({
104
+ file: relPath,
105
+ issue: "invalid-task-yaml",
106
+ detail: taskExtensionDetail(relPath),
107
+ fixed: false,
108
+ });
109
+ }
110
+ const parsed = parseTaskYaml(raw);
111
+ if (!parsed.ok) {
112
+ // Distinguish "unparseable" from "empty": `taskDiagnostics` returns []
113
+ // for an empty mapping, so collapsing a parse failure onto `{}` made a
114
+ // broken task file lint clean.
115
+ diagnostics.push({
116
+ file: relPath,
117
+ issue: "invalid-task-yaml",
118
+ detail: taskYamlParseDetail(parsed.error),
119
+ fixed: false,
120
+ });
121
+ continue;
122
+ }
123
+ diagnostics.push(...taskDiagnostics(relPath, parsed.data));
105
124
  }
106
125
  return diagnostics;
107
126
  }
108
127
  export const akmTaskAdapter = {
109
128
  id: "akm-task",
110
129
  version: "0.9.0",
111
- extensions: [TASK_EXT],
130
+ // `.yaml` is listed as a COLLECTION hint only — `recognize` still gates on
131
+ // `.yml`, so a `.yaml` file is never indexed as a task. Listing it is what
132
+ // routes the near-miss file into `validate`, where it is reported instead of
133
+ // silently skipped (issue #760).
134
+ extensions: [TASK_EXT, TASK_NEAR_MISS_EXTENSION],
112
135
  recognize,
113
136
  validate,
114
137
  /** A task places to `<conceptId>.yml`; an already-suffixed conceptId is idempotent. */
@@ -143,7 +166,7 @@ export const akmTaskAdapter = {
143
166
  catch {
144
167
  continue;
145
168
  }
146
- const data = parseTaskYaml(raw);
169
+ const { data } = parseTaskYaml(raw);
147
170
  if (typeof data.schedule === "string" && data.schedule.trim() !== "")
148
171
  return true;
149
172
  }
@@ -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") {
@@ -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,8 @@
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";
14
+ import { recordWrittenPath } from "../write-provenance.js";
13
15
  import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
14
16
  /**
15
17
  * Parse YAML frontmatter from a Markdown (or similar) string.
@@ -142,7 +144,14 @@ export function mutateFrontmatter(filePath, mutator) {
142
144
  const next = parsed.frontmatter !== null
143
145
  ? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
144
146
  : assembleAsset(nextFrontmatter, parsed.content);
145
- 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));
152
+ // #652: in-place frontmatter stamps (belief state, contradiction markers,
153
+ // salience) are real asset mutations — journal them for the run's sync.
154
+ recordWrittenPath(filePath);
146
155
  return true;
147
156
  }
148
157
  export function parseFrontmatterBlock(raw) {