akm-cli 0.9.0-rc.13 → 0.9.0-rc.14

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 (100) hide show
  1. package/CHANGELOG.md +117 -23
  2. package/dist/akm-migrate +3 -1
  3. package/dist/assets/hints/cli-hints-full.md +3 -4
  4. package/dist/assets/hints/cli-hints-short.md +5 -5
  5. package/dist/assets/workflows/workflow-template.md +4 -3
  6. package/dist/cli/invocation.js +3 -2
  7. package/dist/cli/retired-commands.js +3 -0
  8. package/dist/cli/unknown-flags.js +226 -0
  9. package/dist/cli.js +19 -35
  10. package/dist/commands/agent/contribute-cli.js +15 -3
  11. package/dist/commands/feedback-cli.js +6 -3
  12. package/dist/commands/improve/collapse-detector.js +2 -3
  13. package/dist/commands/lint/base-linter.js +4 -16
  14. package/dist/commands/lint/index.js +13 -13
  15. package/dist/commands/log.js +6 -1
  16. package/dist/commands/migration-tool.js +4 -5
  17. package/dist/commands/observability-cli.js +1 -1
  18. package/dist/commands/proposal/repository.js +5 -5
  19. package/dist/commands/read/knowledge.js +2 -0
  20. package/dist/commands/registry-cli.js +5 -3
  21. package/dist/commands/sources/add-cli.js +6 -6
  22. package/dist/commands/sources/self-update.js +30 -7
  23. package/dist/commands/sources/source-add.js +17 -2
  24. package/dist/commands/tasks/tasks.js +8 -3
  25. package/dist/commands/workflow-cli.js +142 -119
  26. package/dist/core/adapter/adapters/akm-lint.js +17 -13
  27. package/dist/core/adapter/adapters/akm-task-adapter.js +14 -11
  28. package/dist/core/asset/akm-markdown.js +41 -8
  29. package/dist/core/asset/frontmatter.js +22 -0
  30. package/dist/core/asset/resolve-ref.js +23 -3
  31. package/dist/core/common.js +45 -2
  32. package/dist/core/config/config-schema.js +8 -0
  33. package/dist/core/config/schema/experimental.js +5 -13
  34. package/dist/core/config/schema/sources-bundles.js +11 -0
  35. package/dist/core/config/schema/workflow.js +3 -1
  36. package/dist/core/errors.js +5 -0
  37. package/dist/core/logs-db.js +2 -1
  38. package/dist/core/parse.js +4 -1
  39. package/dist/core/state/migrations.js +11 -14
  40. package/dist/core/state-db.js +4 -6
  41. package/dist/core/subprocess.js +6 -4
  42. package/dist/core/type-presentation.js +1 -1
  43. package/dist/indexer/indexer.js +16 -1
  44. package/dist/indexer/search/search-source.js +1 -1
  45. package/dist/indexer/walk/matchers.js +3 -1
  46. package/dist/integrations/agent/config.js +2 -2
  47. package/dist/integrations/agent/detect.js +49 -19
  48. package/dist/integrations/agent/profiles.js +10 -0
  49. package/dist/integrations/agent/spawn.js +1 -2
  50. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +7 -3
  51. package/dist/integrations/lockfile.js +11 -56
  52. package/dist/output/shapes/passthrough.js +0 -4
  53. package/dist/output/text/command-format.js +3 -3
  54. package/dist/output/text/helpers.js +1 -1
  55. package/dist/output/text/show-directives.js +4 -6
  56. package/dist/output/text/workflow-format.js +7 -62
  57. package/dist/output/text/workflow.js +1 -5
  58. package/dist/scripts/akm-migrate-node.js +58773 -0
  59. package/dist/scripts/akm-migrate.js +33976 -11391
  60. package/dist/setup/detect.js +40 -15
  61. package/dist/setup/setup.js +1 -1
  62. package/dist/sources/providers/git-stash.js +4 -2
  63. package/dist/sources/providers/website.js +5 -0
  64. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  65. package/dist/sources/snapshot-fetchers/content-extract.js +370 -0
  66. package/dist/sources/snapshot-fetchers/fetcher-util.js +40 -0
  67. package/dist/sources/snapshot-fetchers/host-guard.js +199 -0
  68. package/dist/sources/snapshot-fetchers/registry.js +10 -1
  69. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  70. package/dist/sources/snapshot-fetchers/rss.js +279 -0
  71. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  72. package/dist/sources/snapshot-fetchers/website-ingest.js +488 -257
  73. package/dist/sources/snapshot-fetchers/x.js +193 -0
  74. package/dist/storage/engines/sqlite-migrations.js +22 -107
  75. package/dist/tasks/runner.js +19 -16
  76. package/dist/tasks/schema.js +24 -1
  77. package/dist/workflows/exec/brief.js +1 -1
  78. package/dist/workflows/exec/frozen-judge.js +28 -2
  79. package/dist/workflows/exec/native-executor.js +18 -1
  80. package/dist/workflows/exec/report.js +11 -4
  81. package/dist/workflows/exec/run-workflow.js +103 -44
  82. package/dist/workflows/exec/step-work.js +19 -18
  83. package/dist/workflows/exec/unit-dispatch.js +4 -0
  84. package/dist/workflows/exec/workflow-engine-gate.js +11 -13
  85. package/dist/workflows/ir/compile.js +2 -2
  86. package/dist/workflows/ir/freeze.js +16 -8
  87. package/dist/workflows/ir/params.js +134 -10
  88. package/dist/workflows/ir/plan-hash.js +1 -1
  89. package/dist/workflows/ir/schema.js +6 -2
  90. package/dist/workflows/renderer.js +2 -2
  91. package/dist/workflows/runtime/checkin.js +3 -3
  92. package/dist/workflows/runtime/runs.js +50 -29
  93. package/dist/workflows/validate-summary.js +30 -14
  94. package/docs/migration/release-notes/0.9.0.md +31 -4
  95. package/docs/migration/v0.8-to-v0.9.md +69 -100
  96. package/docs/reference/data-and-telemetry.md +5 -5
  97. package/package.json +6 -2
  98. package/schemas/akm-config.json +24 -0
  99. package/schemas/akm-workflow.json +1 -1
  100. package/dist/workflows/cli.js +0 -33
@@ -2,111 +2,22 @@
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
  /**
5
- * `akm workflow` command family. Extracted verbatim from src/cli.ts (WS6) so the
6
- * God Module shrinks; the `main.subCommands.workflow` key and every subcommand's
7
- * args/output shape are byte-identical. Handlers whose body is a plain
8
- * `runWithJsonErrors(...) + output(...)` are migrated to `defineJsonCommand`,
9
- * which emits the same JSON envelope (stdout/stderr/exit-code) as the inline
10
- * form.
11
- *
12
- * 0.9.0 CLI overhaul (S5): `workflow template` is dropped — `workflow create
13
- * --print` prints the same content without writing. `workflow validate` is
14
- * dropped — `akm lint --type workflows` covers structural validation.
15
- * `workflow watch` is dropped — poll `akm log --since '@offset:<id>' --run
16
- * <run-id>` instead. Workflows are markdown-only (workflow-format-
17
- * unification) — `workflow create <name>.yaml` is a usage error.
5
+ * `akm workflow` command family. `run` is the canonical start/resume/execute
6
+ * surface; the former public `start`, `next`, and `complete` lifecycle is gone.
7
+ * `brief`/`report` retain the experimental harness-neutral driver protocol.
8
+ * Workflows are markdown-only; authoring uses `create --print` and validation
9
+ * uses `akm lint --type workflows`.
18
10
  */
19
- import { getParsedInvocation } from "../cli/invocation.js";
20
11
  import { getStringArg } from "../cli/parse-args.js";
21
- import { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js";
12
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
22
13
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
23
14
  import { loadConfig } from "../core/config/config.js";
24
15
  import { NotFoundError, UsageError } from "../core/errors.js";
25
16
  import { akmIndex } from "../indexer/indexer.js";
26
17
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
27
- import { parseWorkflowJsonObject, parseWorkflowStepState, WORKFLOW_STEP_STATES } from "../workflows/cli.js";
28
18
  import { requireWorkflowEngineEnabled } from "../workflows/exec/workflow-engine-gate.js";
29
- import { abandonWorkflowRun, completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, startWorkflowRun, } from "../workflows/runtime/runs.js";
30
- const workflowStartCommand = defineJsonCommand({
31
- meta: {
32
- name: "start",
33
- description: "Start a new workflow run in the current working scope",
34
- },
35
- args: {
36
- ref: { type: "positional", description: "Workflow ref (workflows/<name>)", required: true },
37
- params: { type: "string", description: "Workflow parameters as a JSON object" },
38
- force: {
39
- type: "boolean",
40
- description: "Allow a parallel run when an active run already exists in this scope",
41
- default: false,
42
- },
43
- },
44
- async run({ args }) {
45
- const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params"), {
46
- force: args.force === true,
47
- });
48
- output("workflow-start", result);
49
- },
50
- });
51
- const workflowNextCommand = defineJsonCommand({
52
- meta: {
53
- name: "next",
54
- description: "Show the next actionable workflow step in the current scope, auto-starting a run when passed a workflow ref",
55
- },
56
- args: {
57
- target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
58
- params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
59
- },
60
- async run({ args }) {
61
- // `--dry-run` is intentionally NOT a declared arg (so it stays out of
62
- // --help). The guard reads it straight from the invocation singleton so
63
- // existing callers still get a clear, actionable error instead of a
64
- // generic "unknown flag" from citty.
65
- if (getParsedInvocation().hasFlag("--dry-run")) {
66
- throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
67
- }
68
- const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
69
- const result = await getNextWorkflowStep(args.target, parsedParams);
70
- output("workflow-next", result);
71
- },
72
- });
73
- const workflowCompleteCommand = defineJsonCommand({
74
- meta: {
75
- name: "complete",
76
- description: "Update a workflow step state and persist notes/evidence",
77
- },
78
- args: {
79
- runId: { type: "positional", description: "Workflow run id", required: true },
80
- step: { type: "string", description: "Workflow step id", required: true },
81
- state: {
82
- type: "string",
83
- description: `Step state (default: completed). One of: ${WORKFLOW_STEP_STATES.join(", ")}.`,
84
- },
85
- notes: { type: "string", description: "Notes for the completed step" },
86
- summary: {
87
- type: "string",
88
- description: "Summary of work done (required when completing a step); validated against completion criteria",
89
- },
90
- evidence: { type: "string", description: "Evidence JSON object for the step" },
91
- },
92
- async run({ args }) {
93
- const result = await completeWorkflowStep({
94
- runId: args.runId,
95
- stepId: args.step,
96
- status: parseWorkflowStepState(args.state),
97
- notes: args.notes,
98
- summary: args.summary,
99
- evidence: args.evidence ? parseWorkflowJsonObject(args.evidence, "--evidence") : undefined,
100
- });
101
- if ("ok" in result && result.ok === false) {
102
- // Summary failed the completion-criteria validation gate (#506): the
103
- // step stays pending and the agent receives corrective feedback.
104
- output("workflow-complete-rejected", result);
105
- return;
106
- }
107
- output("workflow-complete", result);
108
- },
109
- });
19
+ import { WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
20
+ import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
110
21
  const workflowStatusCommand = defineJsonCommand({
111
22
  meta: {
112
23
  name: "status",
@@ -219,7 +130,7 @@ const workflowCreateCommand = defineJsonCommand({
219
130
  from: args.from,
220
131
  force: args.force,
221
132
  });
222
- // Index the newly-written workflow so `akm workflow start` can resolve
133
+ // Index the newly-written workflow so `akm workflow run` can resolve
223
134
  // a workflowEntryId without requiring an explicit `akm index` call
224
135
  // first. Uses the same incremental index path that `akm add` uses.
225
136
  await akmIndex({ stashDir: result.stashDir });
@@ -229,34 +140,149 @@ const workflowCreateCommand = defineJsonCommand({
229
140
  const workflowRunCommand = defineJsonCommand({
230
141
  meta: {
231
142
  name: "run",
232
- description: "EXPERIMENTAL, gated behind `experimental.workflowEngine`: execute a workflow's steps with the native " +
233
- "engine — akm dispatches each step's units (fan-out, schema output) to the configured runner and advances " +
234
- "the run through the normal completion gates",
143
+ description: "Start or resume a workflow and execute it through completion, failure, a verification gate, or an explicit limit",
235
144
  },
236
145
  args: {
237
146
  target: { type: "positional", description: "Workflow run id or workflow ref (auto-starts a run)", required: true },
238
- params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
239
147
  "max-steps": { type: "string", description: "Stop after executing this many steps" },
148
+ "max-retries": { type: "string", description: "Retry a failed workflow step this many additional times" },
149
+ timeout: { type: "string", description: "Whole-run timeout: N, Nms, Ns, or Nm (bare N is milliseconds)" },
240
150
  },
241
- async run({ args }) {
242
- requireWorkflowEngineEnabled(loadConfig(), "run");
151
+ async run({ args, rawArgs }) {
243
152
  const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
244
- const rawMaxSteps = getStringArg(args, "max-steps");
245
- let maxSteps;
246
- if (rawMaxSteps !== undefined) {
247
- maxSteps = Number.parseInt(rawMaxSteps, 10);
248
- if (!/^\d+$/.test(rawMaxSteps) || maxSteps <= 0) {
249
- throw new UsageError(`--max-steps must be a positive integer, got "${rawMaxSteps}".`, "INVALID_FLAG_VALUE");
153
+ const parameterFlags = parseWorkflowParameterFlags(rawArgs, args.target);
154
+ const maxSteps = parseIntegerFlag(getStringArg(args, "max-steps"), "--max-steps", 1);
155
+ const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0, WORKFLOW_MAX_RETRIES);
156
+ const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
157
+ const controller = new AbortController();
158
+ let timedOut = false;
159
+ let signalExitCode;
160
+ const interrupt = (signal) => {
161
+ signalExitCode = signal === "SIGINT" ? 130 : 143;
162
+ controller.abort(new Error(`Workflow run interrupted by ${signal}.`));
163
+ };
164
+ const onSigint = () => interrupt("SIGINT");
165
+ const onSigterm = () => interrupt("SIGTERM");
166
+ process.once("SIGINT", onSigint);
167
+ process.once("SIGTERM", onSigterm);
168
+ const timer = timeoutMs === undefined
169
+ ? undefined
170
+ : setTimeout(() => {
171
+ timedOut = true;
172
+ controller.abort(new Error(`Workflow run timed out after ${timeoutMs}ms.`));
173
+ }, timeoutMs);
174
+ timer?.unref?.();
175
+ try {
176
+ const result = await runWorkflowSteps({
177
+ target: args.target,
178
+ parameterFlags,
179
+ ...(maxSteps !== undefined ? { maxSteps } : {}),
180
+ ...(maxRetries !== undefined ? { maxRetries } : {}),
181
+ signal: controller.signal,
182
+ });
183
+ const rendered = { ...result, ...(timedOut ? { timedOut: true } : {}) };
184
+ output("workflow-run", rendered);
185
+ if (result.run.status === "failed" || result.gateRejection || result.aborted) {
186
+ process.exitCode = signalExitCode ?? EXIT_CODES.GENERAL;
250
187
  }
251
188
  }
252
- const result = await runWorkflowSteps({
253
- target: args.target,
254
- ...(args.params ? { params: parseWorkflowJsonObject(args.params, "--params") } : {}),
255
- ...(maxSteps !== undefined ? { maxSteps } : {}),
256
- });
257
- output("workflow-run", result);
189
+ finally {
190
+ if (timer)
191
+ clearTimeout(timer);
192
+ process.off("SIGINT", onSigint);
193
+ process.off("SIGTERM", onSigterm);
194
+ }
258
195
  },
259
196
  });
197
+ const WORKFLOW_RUN_VALUE_FLAGS = new Set([
198
+ "max-steps",
199
+ "maxSteps",
200
+ "max-retries",
201
+ "maxRetries",
202
+ "timeout",
203
+ "format",
204
+ "detail",
205
+ "shape",
206
+ "output",
207
+ ]);
208
+ const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose"]);
209
+ export function parseWorkflowParameterFlags(rawArgs, target) {
210
+ const flags = [];
211
+ let targetSeen = false;
212
+ for (let index = 0; index < rawArgs.length; index += 1) {
213
+ const token = rawArgs[index];
214
+ if (token === "--") {
215
+ throw new UsageError("`akm workflow run` does not accept positional arguments after `--`.", "INVALID_FLAG_VALUE");
216
+ }
217
+ if (!token.startsWith("-") || token === "-" || /^-\d/.test(token)) {
218
+ if (!targetSeen) {
219
+ if (token !== target) {
220
+ throw new UsageError("Workflow parameter flags must come after the workflow ref or run id.", "INVALID_FLAG_VALUE");
221
+ }
222
+ targetSeen = true;
223
+ continue;
224
+ }
225
+ throw new UsageError(`Unexpected positional workflow argument "${token}".`, "INVALID_FLAG_VALUE");
226
+ }
227
+ if (!token.startsWith("--"))
228
+ continue;
229
+ const body = token.slice(2);
230
+ const equalsAt = body.indexOf("=");
231
+ const name = equalsAt === -1 ? body : body.slice(0, equalsAt);
232
+ const inlineValue = equalsAt === -1 ? undefined : body.slice(equalsAt + 1);
233
+ if (name === "params") {
234
+ throw new UsageError("--params was removed. Pass each declared workflow parameter as its own flag, for example `--version=1.2.3`.", "INVALID_FLAG_VALUE");
235
+ }
236
+ if (WORKFLOW_RUN_VALUE_FLAGS.has(name)) {
237
+ if (inlineValue === undefined)
238
+ index += 1;
239
+ continue;
240
+ }
241
+ if (WORKFLOW_RUN_BOOLEAN_FLAGS.has(name))
242
+ continue;
243
+ if (!targetSeen) {
244
+ throw new UsageError("Workflow parameter flags must come after the workflow ref or run id.", "INVALID_FLAG_VALUE");
245
+ }
246
+ if (inlineValue !== undefined) {
247
+ flags.push({ name, value: inlineValue });
248
+ continue;
249
+ }
250
+ const next = rawArgs[index + 1];
251
+ if (next !== undefined && (!next.startsWith("-") || /^-\d/.test(next))) {
252
+ flags.push({ name, value: next });
253
+ index += 1;
254
+ }
255
+ else {
256
+ flags.push({ name, value: true });
257
+ }
258
+ }
259
+ return flags;
260
+ }
261
+ function parseIntegerFlag(raw, name, minimum, maximum) {
262
+ if (raw === undefined)
263
+ return undefined;
264
+ const value = Number.parseInt(raw, 10);
265
+ if (!/^\d+$/.test(raw) || value < minimum || (maximum !== undefined && value > maximum)) {
266
+ const range = maximum === undefined ? `at least ${minimum}` : `from ${minimum} through ${maximum}`;
267
+ throw new UsageError(`${name} must be an integer ${range}, got "${raw}".`, "INVALID_FLAG_VALUE");
268
+ }
269
+ return value;
270
+ }
271
+ function parseWorkflowTimeout(raw) {
272
+ if (raw === undefined)
273
+ return undefined;
274
+ const match = /^(\d+)(ms|s|m)?$/.exec(raw);
275
+ if (!match) {
276
+ throw new UsageError(`--timeout must be N, Nms, Ns, or Nm, got "${raw}".`, "INVALID_FLAG_VALUE");
277
+ }
278
+ const amount = Number(match[1]);
279
+ const multiplier = match[2] === "m" ? 60_000 : match[2] === "s" ? 1_000 : 1;
280
+ const timeoutMs = amount * multiplier;
281
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > WORKFLOW_MAX_TIMEOUT_MS) {
282
+ throw new UsageError(`--timeout must resolve to 1 through ${WORKFLOW_MAX_TIMEOUT_MS} milliseconds, got "${raw}".`, "INVALID_FLAG_VALUE");
283
+ }
284
+ return timeoutMs;
285
+ }
260
286
  const workflowBriefCommand = defineJsonCommand({
261
287
  meta: {
262
288
  name: "brief",
@@ -433,9 +459,6 @@ export const workflowCommand = defineGroupCommand({
433
459
  description: "Author, inspect, and execute step-by-step workflow assets",
434
460
  },
435
461
  subCommands: {
436
- start: workflowStartCommand,
437
- next: workflowNextCommand,
438
- complete: workflowCompleteCommand,
439
462
  status: workflowStatusCommand,
440
463
  list: workflowListCommand,
441
464
  create: workflowCreateCommand,
@@ -52,8 +52,10 @@
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
56
  import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
56
57
  import { parseWorkflow } from "../../../workflows/parser.js";
58
+ import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
57
59
  /** Recommended `category` values for facts — `commands/lint/fact-linter.ts:9`. */
58
60
  const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
59
61
  /** Placeholder markers a workflow stub carries — `commands/lint/workflow-linter.ts:10`. */
@@ -152,8 +154,12 @@ function collectSuppressedKeys(raw) {
152
154
  /**
153
155
  * env/secret dangerous-key scan (`lint/index.ts:191-218` + `env-key-rules.ts#checkEnvForDangerousKeys`),
154
156
  * keyed on `type` and preserving the `.env`-suffix narrowness (see file header).
155
- * Reads the overlay `raw`, not disk. `type`==="env" ⇒ ref prefix `env:`;
156
- * `type`==="secret" ⇒ `secret:` (`lint/index.ts:201-204`).
157
+ * Reads the overlay `raw`, not disk.
158
+ *
159
+ * The emitted `Ref:` comes from `conceptIdForStashFile` — the one place that
160
+ * spells a diagnostic ref the way `akm show` accepts it. It used to be
161
+ * hand-built as `env:<base>` / `secret:<base>`, a colon grammar the 0.9.0 ref
162
+ * parser rejects outright — a dead-end ref on a *security* finding.
157
163
  */
158
164
  export function dangerousEnvKeyDiagnostics(type, relPath, raw) {
159
165
  if (type !== "env" && type !== "secret")
@@ -161,9 +167,8 @@ export function dangerousEnvKeyDiagnostics(type, relPath, raw) {
161
167
  const baseNameWithExt = path.basename(relPath);
162
168
  if (!baseNameWithExt.endsWith(".env"))
163
169
  return []; // NARROWNESS: collectEnvFiles only visits *.env
164
- const refPrefix = type === "env" ? "env" : "secret";
165
- const baseName = path.basename(relPath, ".env");
166
- const ref = baseName === "" ? `${refPrefix}:.env` : `${refPrefix}:${baseName}`;
170
+ // `relPath` is already stash-root-relative, so "." IS the stash root here.
171
+ const ref = conceptIdForStashFile(type, ".", relPath);
167
172
  const keys = scanKeys(raw);
168
173
  const suppressed = collectSuppressedKeys(raw);
169
174
  const diagnostics = [];
@@ -259,17 +264,16 @@ export function factDiagnostics(relPath, data) {
259
264
  }
260
265
  return [];
261
266
  }
262
- /** TaskLinter extra check (`task-linter.ts:25-58`). `data` is the parsed YAML. */
267
+ /**
268
+ * TaskLinter extra check (`task-linter.ts:25-58`). `data` is the parsed YAML.
269
+ * Field rules come from the shared {@link taskFieldProblems} (see its doc for
270
+ * the lint-vs-parser reconciliation story); this sweep additionally requires
271
+ * at least one target.
272
+ */
263
273
  export function taskDiagnostics(relPath, data) {
264
274
  if (data === null || Object.keys(data).length === 0)
265
275
  return [];
266
- const missing = [];
267
- if (!("schedule" in data) || typeof data.schedule !== "string" || data.schedule.trim() === "") {
268
- missing.push("schedule");
269
- }
270
- if (!("enabled" in data) || typeof data.enabled !== "boolean") {
271
- missing.push("enabled (must be a boolean)");
272
- }
276
+ const missing = taskFieldProblems(data);
273
277
  const hasTarget = "prompt" in data || "workflow" in data || "command" in data;
274
278
  if (!hasTarget)
275
279
  missing.push("prompt, workflow, or command");
@@ -12,11 +12,13 @@
12
12
  *
13
13
  * ── validate (spec §6 task validation column) ──
14
14
  *
15
- * A task must declare a `schedule`, an `enabled` boolean, and EXACTLY ONE
16
- * target (`prompt` XOR `workflow` XOR `command`). The akm adapter's
17
- * `TaskLinter` port checks "at least one" target; the native task family here is
18
- * STRICTER — declaring two targets is `invalid-task-yaml`. So this adapter owns
19
- * a purpose-built one-target check rather than reusing that port.
15
+ * A task must declare `version: 2`, a `schedule`, and EXACTLY ONE target
16
+ * (`prompt` XOR `workflow` XOR `command`). `enabled` is OPTIONAL — the parser
17
+ * defaults it to `true`, so an omitted field means an ACTIVE task — but must
18
+ * be a boolean when present. The akm adapter's `TaskLinter` port checks
19
+ * "at least one" target; the native task family here is STRICTER — declaring
20
+ * two targets is `invalid-task-yaml`. So this adapter owns a purpose-built
21
+ * one-target check rather than reusing that port.
20
22
  *
21
23
  * Conformance oracle (authored, DO NOT modify): fixture
22
24
  * `tests/fixtures/bundles/akm-task/` + goldens
@@ -25,6 +27,7 @@
25
27
  import fs from "node:fs";
26
28
  import path from "node:path";
27
29
  import { parse as parseYaml } from "yaml";
30
+ import { taskFieldProblems } from "../../../tasks/schema.js";
28
31
  import { hashContent } from "./shared.js";
29
32
  /** A native task bundle is single-component; its one component is `main`. */
30
33
  const COMPONENT_ID = "main";
@@ -68,15 +71,15 @@ function parseTaskYaml(raw) {
68
71
  }
69
72
  return {};
70
73
  }
71
- /** The native `invalid-task-yaml` check: schedule + enabled(boolean) + EXACTLY ONE target. */
74
+ /**
75
+ * The native `invalid-task-yaml` check: the shared field rules
76
+ * ({@link taskFieldProblems} — see its doc for the lint-vs-parser
77
+ * reconciliation story) plus this adapter's stricter EXACTLY-ONE-target rule.
78
+ */
72
79
  function taskDiagnostics(relPath, data) {
73
80
  if (Object.keys(data).length === 0)
74
81
  return [];
75
- const problems = [];
76
- if (typeof data.schedule !== "string" || data.schedule.trim() === "")
77
- problems.push("schedule");
78
- if (typeof data.enabled !== "boolean")
79
- problems.push("enabled (must be a boolean)");
82
+ const problems = taskFieldProblems(data);
80
83
  const targets = TARGET_KEYS.filter((k) => k in data && data[k] !== undefined && data[k] !== null);
81
84
  if (targets.length === 0)
82
85
  problems.push("exactly one target (prompt, workflow, or command)");
@@ -2,14 +2,36 @@
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 { parse as parseYaml } from "yaml";
5
+ import { localDateStamp } from "../common.js";
5
6
  import { UsageError } from "../errors.js";
6
7
  import { serializeFrontmatter } from "./asset-serialize.js";
7
- import { parseFrontmatterBlock } from "./frontmatter.js";
8
- /** Ensure an AKM-authored Markdown concept is also a conformant OKF concept. */
9
- export function ensureAkmMarkdownType(content, type) {
8
+ import { parseFrontmatterBlock, spliceFrontmatterLine } from "./frontmatter.js";
9
+ /**
10
+ * Ensure an AKM-authored Markdown concept is also a conformant OKF concept.
11
+ *
12
+ * Stamps BOTH `type` and `updated`, because both are required of a conformant
13
+ * document and this is the one chokepoint every `.md` write passes through
14
+ * (`core/write-source.ts`). Without the `updated` stamp, every asset akm
15
+ * created for you — `akm remember`, `akm import`, accepted proposals,
16
+ * authored workflows — was immediately flagged `missing-updated` by akm's own
17
+ * `akm lint`, so the tool disagreed with itself about its own output.
18
+ *
19
+ * An existing `updated` is left alone: this fills a gap, it does not
20
+ * re-stamp on every write (which would churn timestamps and manufacture
21
+ * needless diffs in git-backed bundles).
22
+ *
23
+ * Source preservation: when the type already matches and the ONLY change is
24
+ * adding `updated`, the line is spliced into the original block textually —
25
+ * round-tripping through the YAML serializer would drop user-authored
26
+ * comments and normalize formatting just to contribute one field. Only a
27
+ * document whose `type` must actually be corrected takes the re-serialize
28
+ * path (as it always has).
29
+ */
30
+ export function ensureAkmMarkdownType(content, type, now = new Date()) {
10
31
  const block = parseFrontmatterBlock(content);
11
- if (!block)
12
- return `---\ntype: ${type}\n---\n${content}`;
32
+ if (!block) {
33
+ return `---\n${serializeFrontmatter({ type, updated: localDateStamp(now) })}\n---\n${content}`;
34
+ }
13
35
  let parsed;
14
36
  try {
15
37
  parsed = block.frontmatter.trim() ? parseYaml(block.frontmatter) : {};
@@ -23,8 +45,19 @@ export function ensureAkmMarkdownType(content, type) {
23
45
  throw new UsageError("AKM Markdown frontmatter must be a YAML mapping.", "INVALID_FLAG_VALUE");
24
46
  }
25
47
  const data = parsed;
26
- if (data.type === type)
27
- return content;
48
+ const needsUpdated = !("updated" in data);
49
+ if (data.type === type) {
50
+ if (!needsUpdated)
51
+ return content;
52
+ const spliced = spliceFrontmatterLine(content, `updated: ${localDateStamp(now)}`);
53
+ if (spliced !== null)
54
+ return spliced;
55
+ // Unreachable in practice (parseFrontmatterBlock succeeded above), but a
56
+ // re-serialized document beats a non-conformant one.
57
+ }
28
58
  const { type: _priorType, ...rest } = data;
29
- return `---\n${serializeFrontmatter({ type, ...rest })}\n---\n${block.content}`;
59
+ const next = { type, ...rest };
60
+ if (needsUpdated)
61
+ next.updated = localDateStamp(now);
62
+ return `---\n${serializeFrontmatter(next)}\n---\n${block.content}`;
30
63
  }
@@ -176,6 +176,28 @@ function countLines(text) {
176
176
  return 0;
177
177
  return text.split(/\r?\n/).length - 1;
178
178
  }
179
+ /**
180
+ * Insert one `key: value` line just before the closing `---` of an existing
181
+ * frontmatter block, leaving every other byte — YAML comments, quoting, key
182
+ * order, line endings — untouched. Returns null when `raw` has no well-formed
183
+ * block, so callers can fall back to a parse-and-serialize path.
184
+ *
185
+ * This is the source-preserving way to ADD a field to user-authored
186
+ * frontmatter: round-tripping the mapping through the YAML serializer drops
187
+ * comments and normalizes formatting, which is unacceptable for a write that
188
+ * only needs to contribute one line. Shared by `ensureAkmMarkdownType`
189
+ * (stamping `updated:` on write) and lint's `--fix` for `missing-updated`.
190
+ */
191
+ export function spliceFrontmatterLine(raw, line) {
192
+ const lines = raw.split(/\r?\n/);
193
+ if (lines[0]?.trim() !== "---")
194
+ return null;
195
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
196
+ if (closeIdx === -1)
197
+ return null;
198
+ lines.splice(closeIdx, 0, line);
199
+ return lines.join("\n");
200
+ }
179
201
  /**
180
202
  * Parse a YAML scalar value (string, boolean, or number).
181
203
  *
@@ -34,7 +34,7 @@
34
34
  * grammar to bridge any more.
35
35
  */
36
36
  import { NotFoundError, UsageError } from "../errors.js";
37
- import { placementSpecFor, stashDirFor, typeForStashDir } from "./asset-placement.js";
37
+ import { deriveCanonicalAssetNameFromStashRoot, placementSpecFor, stashDirFor, typeForStashDir, } from "./asset-placement.js";
38
38
  import { isBundleSlug, parseBundleRef } from "./asset-ref.js";
39
39
  /**
40
40
  * Resolve a maybe-short input ref to a fully-qualified {@link ResolvedRef}
@@ -97,6 +97,19 @@ export function conceptIdFromTypeName(type, name) {
97
97
  const stashDir = stashDirFor(type);
98
98
  return stashDir !== undefined ? `${stashDir}/${name}` : name;
99
99
  }
100
+ /**
101
+ * User-facing conceptId for a file on disk, derived through the placement
102
+ * spec's canonical-name rule — the ONE way a diagnostic should spell a ref it
103
+ * expects the user to paste into `akm show`. (The dangerous-env-key lint used
104
+ * to hand-build `env:<base>` colon refs the parser rejects; both its emission
105
+ * sites now route through here.) For a type with no placement spec — which no
106
+ * built-in caller passes — falls back to the raw name so the output is still
107
+ * informative rather than empty.
108
+ */
109
+ export function conceptIdForStashFile(type, stashRoot, filePath) {
110
+ const name = deriveCanonicalAssetNameFromStashRoot(type, stashRoot, filePath);
111
+ return name === undefined ? filePath : conceptIdFromTypeName(type, name);
112
+ }
100
113
  /**
101
114
  * Build the USER-FACING / envelope ref string for an indexed item, applying the
102
115
  * Chunk-5 flip F4b output-spelling rule (orchestrator decision; ref-grammar
@@ -116,8 +129,15 @@ export function conceptIdFromTypeName(type, name) {
116
129
  * derived slug bundle id, never the retired `origin//type:name` spelling.
117
130
  */
118
131
  export function displayRef(item, defaultBundleId) {
119
- const conceptId = item.conceptId ?? conceptIdFromTypeName(item.type, item.name);
120
- const { bundleId } = item;
132
+ return displayRefForConceptId(item.conceptId ?? conceptIdFromTypeName(item.type, item.name), item.bundleId, defaultBundleId);
133
+ }
134
+ /**
135
+ * The F4b output-spelling flip itself, for a caller that already holds the
136
+ * conceptId (no `type`/`name` derivation needed — e.g. lint findings built
137
+ * from {@link conceptIdForStashFile}). {@link displayRef} delegates here, so
138
+ * the short-default / qualified-secondary rule still has exactly one home.
139
+ */
140
+ export function displayRefForConceptId(conceptId, bundleId, defaultBundleId) {
121
141
  // Default/primary bundle → SHORT conceptId (the flip).
122
142
  if (bundleId === undefined || bundleId === defaultBundleId)
123
143
  return conceptId;
@@ -409,7 +409,7 @@ export async function fetchWithRetry(url, init, options) {
409
409
  if (attempt < maxRetries && shouldRetry(response.status)) {
410
410
  const retryAfter = parseRetryAfter(response);
411
411
  const delay = retryAfter ?? baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
412
- await new Promise((r) => setTimeout(r, delay));
412
+ await abortableDelay(delay, init?.signal);
413
413
  continue;
414
414
  }
415
415
  return response;
@@ -417,12 +417,41 @@ export async function fetchWithRetry(url, init, options) {
417
417
  catch (err) {
418
418
  if (attempt >= maxRetries)
419
419
  throw err;
420
+ // A caller-supplied abort is terminal: never keep retrying past it.
421
+ if (init?.signal?.aborted)
422
+ throw err;
420
423
  const delay = baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
421
- await new Promise((r) => setTimeout(r, delay));
424
+ await abortableDelay(delay, init?.signal);
422
425
  }
423
426
  }
424
427
  throw new Error("fetchWithRetry: unreachable");
425
428
  }
429
+ /**
430
+ * Sleep, but wake immediately if `signal` aborts.
431
+ *
432
+ * A server-supplied `Retry-After` is honored verbatim and can be arbitrarily
433
+ * large. Sleeping it out with a bare `setTimeout` ignored the caller's abort
434
+ * signal entirely, so a single `429` could park an operation far past any
435
+ * deadline its caller believed it had imposed — the request timeout bounds
436
+ * only the request, never the wait between attempts.
437
+ */
438
+ function abortableDelay(ms, signal) {
439
+ if (!signal)
440
+ return new Promise((resolve) => setTimeout(resolve, ms));
441
+ if (signal.aborted)
442
+ return Promise.reject(signal.reason ?? new Error("Aborted"));
443
+ return new Promise((resolve, reject) => {
444
+ const onAbort = () => {
445
+ clearTimeout(timer);
446
+ reject(signal.reason ?? new Error("Aborted"));
447
+ };
448
+ const timer = setTimeout(() => {
449
+ signal.removeEventListener("abort", onAbort);
450
+ resolve();
451
+ }, ms);
452
+ signal.addEventListener("abort", onAbort, { once: true });
453
+ });
454
+ }
426
455
  function shouldRetry(status) {
427
456
  return status === 429 || status >= 500;
428
457
  }
@@ -628,6 +657,20 @@ export function toErrorMessage(error) {
628
657
  export function todayIso() {
629
658
  return new Date().toISOString().slice(0, 10);
630
659
  }
660
+ /**
661
+ * `YYYY-MM-DD` in LOCAL time — deliberately not {@link todayIso}, which is
662
+ * UTC and can differ near midnight. This is the spelling the `updated:`
663
+ * frontmatter stampers share (`core/asset/akm-markdown.ts` on write,
664
+ * `commands/lint/base-linter.ts` on `--fix`), so the field's format has one
665
+ * definition even though the two stampers pick different instants (now vs
666
+ * file mtime).
667
+ */
668
+ export function localDateStamp(d) {
669
+ const y = d.getFullYear();
670
+ const m = String(d.getMonth() + 1).padStart(2, "0");
671
+ const day = String(d.getDate()).padStart(2, "0");
672
+ return `${y}-${m}-${day}`;
673
+ }
631
674
  /**
632
675
  * Return a filesystem-safe timestamp string derived from the current instant.
633
676
  * Colons and dots are replaced with hyphens so the result is safe as a
@@ -234,6 +234,14 @@ export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) =>
234
234
  message: "llmEngine must name an LLM engine",
235
235
  });
236
236
  }
237
+ const workflowJudge = config.workflow?.judgeEngine;
238
+ if (workflowJudge && !config.engines?.[workflowJudge]) {
239
+ ctx.addIssue({
240
+ code: z.ZodIssueCode.custom,
241
+ path: ["workflow", "judgeEngine"],
242
+ message: "judgeEngine does not name a configured engine",
243
+ });
244
+ }
237
245
  const defaultStrategy = config.defaults?.improveStrategy;
238
246
  if (defaultStrategy &&
239
247
  !BUILTIN_IMPROVE_STRATEGY_NAMES.includes(defaultStrategy) &&