akm-cli 0.9.10 → 0.9.12

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 (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -0,0 +1,186 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm task validate <path>` (#907) — parse ONE task file by filesystem
6
+ * path, not a concept ref/id, and report the same diagnostic `akm task
7
+ * sync` would produce for it. The file need not live in any configured
8
+ * bundle: unlike every other `akm task` subcommand, this one never resolves
9
+ * a bundle/adapter/concept id at all — it reads exactly the path it was
10
+ * given and classifies it.
11
+ *
12
+ * Reuses the exact version-routing shim `parseTaskSource`
13
+ * (`src/tasks/source/parse-task-source.ts`) already applies for every other
14
+ * task-source reader (`akm task sync`'s `compileTaskSources` included) —
15
+ * this module never forks a second parser or a second v2/v3 migration
16
+ * planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
17
+ * the SAME front-end helpers that shim itself calls first; they are used
18
+ * here only to recover the file's ORIGINALLY DECLARED schema version for
19
+ * the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
20
+ * always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
21
+ * its own once a v2/v3 source has been converted in memory.
22
+ *
23
+ * Beyond parsing, this module also runs the SAME two per-source gates
24
+ * `akm task sync`'s `compileTaskSources` runs before it ever installs a
25
+ * schedule — `assertTaskScheduleInputsSatisfyContract` and
26
+ * `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
27
+ * exactly this reuse) — so a file `sync` would reject can never
28
+ * be reported `valid`/`converts` here. Cron dialect is checked against
29
+ * `backendNameForPlatform()`, the same platform default `sync` falls back
30
+ * to whenever it has no injected/native-inspected backend to hand (see
31
+ * `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
32
+ * installed anywhere; there is no native scheduler state to inspect for it.
33
+ *
34
+ * Deliberately DOES NOT call `prepareTaskV3Execution` (the function
35
+ * `compileTaskSources` calls between those two gates) or resolve an
36
+ * execution engine: that path resolves a composed command/persona ref
37
+ * against the local index and lowers the task's cascade-composed
38
+ * engine/model — both of which assume a real, indexed bundle and a
39
+ * configured engine. A bare file passed to `validate` has neither, so
40
+ * running that step would make an otherwise-valid command-kind task report
41
+ * `invalid` on any machine with no engine configured. `resolved`
42
+ * is therefore the compiled task shape `sync` itself would build a
43
+ * scheduler binding from — id, the compiled v4 `version`, `target`
44
+ * (`uses`/`run`), the declared `inputs` contract, and `schedule` bindings —
45
+ * never an execution-lowered plan.
46
+ *
47
+ * Outcome classification (mirrors `parse-task-source.ts`'s own routing
48
+ * table in its header, extended for the two gates above):
49
+ * - `valid` — parses as task source v4 directly (declared `version: 4`)
50
+ * and passes both sync gates.
51
+ * - `converts` — declared `version: 2` or `3`; the deterministic
52
+ * in-memory migrator produced a valid v4 document that
53
+ * passes both sync gates.
54
+ * - `blocked` — declared `version: 2` or `3`; the migrator itself
55
+ * could not convert it (an ambiguous/unmigratable
56
+ * shape) — the ONLY way `parseTaskSource` ever throws
57
+ * for those two version numbers, so no message-text
58
+ * sniffing is needed to tell this apart from `invalid`.
59
+ * - `invalid` — the document declares SOME version (`4`, or anything
60
+ * other than 2/3/4) but fails to parse/validate, OR it
61
+ * parsed (directly or via a SUCCESSFUL v2/v3
62
+ * conversion) but fails one of the two sync gates
63
+ * above, OR the YAML itself does not parse at all
64
+ * (a genuine syntax error, not merely a non-task
65
+ * shape) — reported with the parser's own reason.
66
+ * - `not-a-task` — the document parses as YAML but never declares a
67
+ * `version:` field at all (or isn't a YAML mapping) —
68
+ * the strongest signal available that the file was
69
+ * never intended as a task source in the first place.
70
+ */
71
+ import fs from "node:fs";
72
+ import path from "node:path";
73
+ import { UsageError } from "../../core/errors.js";
74
+ import { backendNameForPlatform } from "../../tasks/backends/index.js";
75
+ import { assertTaskScheduleCronValid, assertTaskScheduleInputsSatisfyContract } from "../../tasks/scheduler-sync.js";
76
+ import { own, readBoundedTaskSourceYaml } from "../../tasks/source/bounded-document.js";
77
+ import { parseTaskSource, peekTaskSourceVersion } from "../../tasks/source/parse-task-source.js";
78
+ /** True when `root` is a YAML mapping that itself declares a `version:` key, regardless of that key's type/value. */
79
+ function declaresVersionKey(root) {
80
+ return root !== null && typeof root === "object" && !Array.isArray(root) && own(root, "version");
81
+ }
82
+ function buildResolved(id, v4) {
83
+ return {
84
+ id,
85
+ version: v4.version,
86
+ ...(v4.name !== undefined ? { name: v4.name } : {}),
87
+ ...(v4.description !== undefined ? { description: v4.description } : {}),
88
+ target: v4.target,
89
+ inputs: v4.inputs ?? {},
90
+ schedule: v4.schedule,
91
+ };
92
+ }
93
+ export async function akmTaskValidate(filePath) {
94
+ const resolvedPath = path.resolve(filePath);
95
+ let stat;
96
+ try {
97
+ stat = fs.statSync(resolvedPath);
98
+ }
99
+ catch {
100
+ throw new UsageError(`Task file not found: ${JSON.stringify(filePath)}.`, "INVALID_FLAG_VALUE");
101
+ }
102
+ if (!stat.isFile()) {
103
+ throw new UsageError(`${JSON.stringify(filePath)} is not a regular file.`, "INVALID_FLAG_VALUE");
104
+ }
105
+ let yaml;
106
+ try {
107
+ yaml = fs.readFileSync(resolvedPath, "utf8");
108
+ }
109
+ catch (cause) {
110
+ throw new UsageError(`Task file ${JSON.stringify(filePath)} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_FLAG_VALUE");
111
+ }
112
+ // Peek the declared version BEFORE the real parse, using the identical
113
+ // bounded YAML front end `parseTaskSource` calls internally — never a
114
+ // second/looser YAML reader. A front-end failure here (unparseable YAML,
115
+ // not a mapping, exceeds a resource bound) means the document itself does
116
+ // not parse at all — tracked as `peekFailed` so that case reports
117
+ // `invalid`, never `not-a-task` (`not-a-task` is reserved for
118
+ // YAML that DOES parse but never declared a task shape). The real parse
119
+ // below throws the identical error either way, so nothing is lost by
120
+ // swallowing it here.
121
+ let root;
122
+ let peekFailed = false;
123
+ try {
124
+ root = readBoundedTaskSourceYaml({ yaml, filePath: resolvedPath }, { sourceLabel: "task source" }).root;
125
+ }
126
+ catch {
127
+ peekFailed = true;
128
+ }
129
+ const declaredVersion = peekFailed ? undefined : peekTaskSourceVersion(root);
130
+ const hasVersionKey = !peekFailed && declaresVersionKey(root);
131
+ const workspaceRoot = path.dirname(resolvedPath);
132
+ const backend = backendNameForPlatform();
133
+ let parsed;
134
+ try {
135
+ parsed = parseTaskSource({ yaml, filePath: resolvedPath, workspaceRoot });
136
+ }
137
+ catch (cause) {
138
+ if (!(cause instanceof UsageError))
139
+ throw cause;
140
+ const reason = cause.message;
141
+ // `parseTaskSource` only ever throws for a declared version 2/3 via the
142
+ // unmigratable-conversion branch (see this file's header) — no separate
143
+ // message check needed to recognize "blocked" here.
144
+ if (declaredVersion === 2 || declaredVersion === 3) {
145
+ return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
146
+ }
147
+ if (peekFailed) {
148
+ return { ok: false, path: resolvedPath, outcome: "invalid", reason };
149
+ }
150
+ if (!hasVersionKey) {
151
+ return { ok: false, path: resolvedPath, outcome: "not-a-task", reason };
152
+ }
153
+ return {
154
+ ok: false,
155
+ path: resolvedPath,
156
+ ...(declaredVersion !== undefined ? { sourceVersion: declaredVersion } : {}),
157
+ outcome: "invalid",
158
+ reason,
159
+ };
160
+ }
161
+ // Success is unreachable from any path that leaves `declaredVersion`
162
+ // undefined — the router requires a numeric 2/3/4 version to reach here.
163
+ const sourceVersion = declaredVersion ?? 4;
164
+ // The document itself parsed (directly, or via a successful v2/v3
165
+ // conversion) — now the two gates `compileTaskSources` runs before
166
+ // accepting it. A violation here is `invalid`, never `blocked`: the
167
+ // migrator already succeeded, so this is the same kind of defect a
168
+ // native v4 document with the identical schedule would have.
169
+ try {
170
+ assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
171
+ assertTaskScheduleCronValid(parsed.v4, backend);
172
+ }
173
+ catch (cause) {
174
+ if (!(cause instanceof UsageError))
175
+ throw cause;
176
+ return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
177
+ }
178
+ const id = path.parse(resolvedPath).name;
179
+ return {
180
+ ok: true,
181
+ path: resolvedPath,
182
+ sourceVersion,
183
+ outcome: sourceVersion === 2 || sourceVersion === 3 ? "converts" : "valid",
184
+ resolved: buildResolved(id, parsed.v4),
185
+ };
186
+ }
@@ -1,34 +1,93 @@
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
+ import { concurrentMap } from "../core/concurrent.js";
5
+ import { systemErrorCode } from "../core/system-error.js";
4
6
  const URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
7
+ /**
8
+ * URLs are checked `URL_CHECK_CONCURRENCY` at a time via {@link concurrentMap}
9
+ * rather than all at once: a knowledge bundle can hold thousands of links, and
10
+ * firing every HEAD request in one `Promise.allSettled` batch is a
11
+ * self-inflicted denial-of-service against whatever host happens to be linked
12
+ * most. Bounding concurrency changes only how fast the check runs, never how
13
+ * much of it happens — every URL still gets checked.
14
+ */
15
+ const URL_CHECK_CONCURRENCY = 8;
16
+ /**
17
+ * Per-HEAD-request timeout, matching the old `TIMEOUT_MS` the caps this
18
+ * checker replaced used. A dead site rarely refuses cleanly — it hangs — so
19
+ * without this a handful of unresponsive hosts could stall the whole check
20
+ * indefinitely instead of the timed-out URLs simply showing up as dead.
21
+ */
22
+ const DEAD_URL_TIMEOUT_MS = 5000;
23
+ /**
24
+ * DNS/connection-level codes meaning "this machine could not reach the host
25
+ * right now" — a corporate DNS block, an offline sandbox, a transient blip —
26
+ * as opposed to "the resource is gone". Same grouping `classifyVectorFailure`
27
+ * (indexer/search/db-search.ts) uses for its "connection failed" bucket.
28
+ * These are counted in `coverage.skipped`, never reported as a `DeadUrl`: an
29
+ * indeterminate result is not evidence a link is dead.
30
+ */
31
+ const NETWORK_ERROR_CODES = new Set([
32
+ "ECONNREFUSED",
33
+ "ECONNRESET",
34
+ "ENETUNREACH",
35
+ "EHOSTUNREACH",
36
+ "ENOTFOUND",
37
+ "EAI_AGAIN",
38
+ ]);
5
39
  /**
6
40
  * Check every URL in `entries` and report the ones that are dead.
7
41
  *
8
42
  * No cap, no per-entry slice, no ceiling option. There used to be a
9
43
  * `MAX_URLS = 20` plus an undocumented `slice(0, 3)` per entry, so this
10
44
  * examined at most twenty links in a bundle holding thousands and reported
11
- * success. The first attempt at fixing that kept the cap and layered a
12
- * coverage report, a warning, and an override on top — a constraint plus more
13
- * code to explain the constraint. Both are gone. It checks what you asked it
14
- * to check.
45
+ * success. Both are gone. It checks what you asked it to check, at a bounded
46
+ * concurrency (see {@link URL_CHECK_CONCURRENCY}) so a large bundle does not
47
+ * turn into a request flood, and each request is bounded by
48
+ * {@link DEAD_URL_TIMEOUT_MS} so one unresponsive host cannot stall the rest.
15
49
  *
16
- * A request that fails, times out, or cannot resolve surfaces as a `DeadUrl`
17
- * rather than being swallowed, so a network problem is visible instead of
18
- * looking like a clean bill of health.
50
+ * A `>=400` response or a timeout surfaces as a `DeadUrl` (a timeout as
51
+ * `status: "timeout"`) rather than being swallowed, so a network problem is
52
+ * visible instead of looking like a clean bill of health. A DNS/connection
53
+ * failure (see {@link NETWORK_ERROR_CODES}) is different: it says the check
54
+ * itself could not run, not that the URL is dead, so it is counted in
55
+ * `coverage.skipped` instead of either `deadUrls` or a silent success. Any
56
+ * other thrown error still reports as `status: "error"`. `coverage.checked`
57
+ * and `coverage.total` are equal only when nothing was skipped this way.
19
58
  */
20
59
  export async function checkDeadUrls(_stashDir, entries) {
21
60
  const urlsToCheck = entries.flatMap((entry) => (entry.body.match(URL_RE) ?? []).map((url) => ({ ref: entry.ref, url })));
22
- const results = [];
23
- await Promise.allSettled(urlsToCheck.map(async ({ ref, url }) => {
61
+ const outcomes = await concurrentMap(urlsToCheck, async ({ ref, url }) => {
24
62
  try {
25
- const res = await fetch(url, { method: "HEAD", redirect: "follow" });
26
- if (res.status >= 400)
27
- results.push({ ref, url, status: res.status });
63
+ const res = await fetch(url, {
64
+ method: "HEAD",
65
+ redirect: "follow",
66
+ signal: AbortSignal.timeout(DEAD_URL_TIMEOUT_MS),
67
+ });
68
+ return res.status >= 400 ? { dead: { ref, url, status: res.status } } : {};
28
69
  }
29
- catch {
30
- results.push({ ref, url, status: "error" });
70
+ catch (err) {
71
+ if (err instanceof DOMException && err.name === "TimeoutError") {
72
+ return { dead: { ref, url, status: "timeout" } };
73
+ }
74
+ const code = systemErrorCode(err);
75
+ if (code && NETWORK_ERROR_CODES.has(code)) {
76
+ return { skipped: true };
77
+ }
78
+ return { dead: { ref, url, status: "error" } };
31
79
  }
32
- }));
33
- return results;
80
+ }, URL_CHECK_CONCURRENCY);
81
+ const deadUrls = [];
82
+ let skipped = 0;
83
+ for (const outcome of outcomes) {
84
+ if (outcome?.dead)
85
+ deadUrls.push(outcome.dead);
86
+ if (outcome?.skipped)
87
+ skipped += 1;
88
+ }
89
+ return {
90
+ deadUrls,
91
+ coverage: { checked: urlsToCheck.length - skipped, total: urlsToCheck.length, skipped },
92
+ };
34
93
  }
@@ -8,14 +8,14 @@
8
8
  * GitHub-shaped `.yml` workflow sources. Validate with `akm lint --type workflows`.
9
9
  */
10
10
  import { getStringArg } from "../cli/parse-args.js";
11
- import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
11
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output, outputWithExitCode } from "../cli/shared.js";
12
12
  import { armAbortDeadline } from "../core/abort-deadline.js";
13
13
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
14
14
  import { NotFoundError, UsageError } from "../core/errors.js";
15
15
  import { akmIndex } from "../indexer/indexer.js";
16
16
  import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
17
17
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
18
- import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
18
+ import { abandonWorkflowRun, getWorkflowStatus, listWorkflowRuns, resolveWorkflowRunTarget, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
19
19
  import { akmWorkflowPlan } from "./workflow/plan.js";
20
20
  const workflowStatusCommand = defineJsonCommand({
21
21
  meta: {
@@ -34,8 +34,9 @@ const workflowStatusCommand = defineJsonCommand({
34
34
  async run({ args }) {
35
35
  const target = args.target;
36
36
  const includeUnits = args.units === true;
37
- if (await hasWorkflowRun(target)) {
38
- const result = await getWorkflowStatus(target, { includeUnits });
37
+ const resolvedRunId = await resolveWorkflowRunTarget(target);
38
+ if (resolvedRunId !== undefined) {
39
+ const result = await getWorkflowStatus(resolvedRunId, { includeUnits });
39
40
  output("workflow-status", result);
40
41
  return;
41
42
  }
@@ -100,12 +101,12 @@ const workflowCreateCommand = defineJsonCommand({
100
101
  },
101
102
  force: {
102
103
  type: "boolean",
103
- description: "Overwrite an existing workflow (requires --from or --reset)",
104
+ description: "Overwrite an existing workflow. Combined with --from, replaces its content; alone, replaces it with a fresh template.",
104
105
  default: false,
105
106
  },
106
107
  reset: {
107
108
  type: "boolean",
108
- description: "Explicitly replace an existing workflow with a fresh template (use with --force)",
109
+ description: "Deprecated alias for --force with no --from (replaces an existing workflow with a fresh template).",
109
110
  default: false,
110
111
  },
111
112
  print: {
@@ -130,9 +131,6 @@ const workflowCreateCommand = defineJsonCommand({
130
131
  process.stdout.write(getWorkflowTemplate());
131
132
  return;
132
133
  }
133
- if (args.force && !args.from && !args.reset) {
134
- throw new UsageError("Refusing to overwrite with template: pass --from <file> to replace content, or --reset to explicitly replace with a fresh template.");
135
- }
136
134
  const result = createWorkflowAsset({
137
135
  name: effectiveName,
138
136
  from: args.from,
@@ -157,6 +155,12 @@ const workflowRunCommand = defineJsonCommand({
157
155
  "max-steps": { type: "string", description: "Stop after executing this many steps" },
158
156
  "max-retries": { type: "string", description: "Retry a failed workflow step this many additional times" },
159
157
  timeout: { type: "string", description: "Whole-run timeout: N, Nms, Ns, or Nm (bare N is milliseconds)" },
158
+ new: {
159
+ type: "boolean",
160
+ description: "Start a fresh run even if one is already active for this ref, leaving the existing run untouched " +
161
+ "(never abandons it). A workflow ref only — passing a run id with --new is a usage error.",
162
+ default: false,
163
+ },
160
164
  },
161
165
  async run({ args, rawArgs }) {
162
166
  const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
@@ -186,6 +190,7 @@ const workflowRunCommand = defineJsonCommand({
186
190
  parameterFlags,
187
191
  ...(maxSteps !== undefined ? { maxSteps } : {}),
188
192
  ...(maxRetries !== undefined ? { maxRetries } : {}),
193
+ newRun: args.new,
189
194
  signal: controller.signal,
190
195
  });
191
196
  // The abort is observed between steps, so a deadline landing in the run's
@@ -193,14 +198,11 @@ const workflowRunCommand = defineJsonCommand({
193
198
  // timed out would send an operator to resume a run with nothing left to
194
199
  // resume — `tasks/runner.ts` suppresses the same case.
195
200
  const timedOut = deadline.timedOut() && result.run.status !== "completed";
196
- const rendered = { ...result, ...(timedOut ? { timedOut: true } : {}) };
197
- output("workflow-run", rendered);
198
201
  // `blocked` is a stopped, unverified run — a verification-judge failure
199
202
  // leaves it there for `akm workflow resume` — so it must not exit 0 and
200
203
  // read as success to a script (it maps to 1 for scheduled tasks too).
201
- if (result.run.status === "failed" || result.run.status === "blocked" || result.gateRejection || result.aborted) {
202
- process.exitCode = signalExitCode ?? EXIT_CODES.GENERAL;
203
- }
204
+ const failed = result.run.status === "failed" || result.run.status === "blocked" || result.gateRejection || result.aborted;
205
+ outputWithExitCode("workflow-run", { ...result, ...(timedOut ? { timedOut: true } : {}) }, failed ? (signalExitCode ?? EXIT_CODES.GENERAL) : undefined);
204
206
  }
205
207
  finally {
206
208
  deadline.disarm();
@@ -220,7 +222,7 @@ const WORKFLOW_RUN_VALUE_FLAGS = new Set([
220
222
  "shape",
221
223
  "output",
222
224
  ]);
223
- const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose"]);
225
+ const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose", "new", "no-new"]);
224
226
  export function parseWorkflowParameterFlags(rawArgs, target) {
225
227
  const flags = [];
226
228
  let targetSeen = false;
@@ -1,20 +1,48 @@
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
+ * `EDITOR`/`VISUAL`/`PAGER` are flagged by `isDangerousEnvKey` for a
6
+ * documented RCE vector (many tools invoke them to launch an editor/pager),
7
+ * but env-key-rules.ts's own module doc calls out their "high FP rate" in
8
+ * the same breath — an installed bundle's env file can supply a value, but
9
+ * nothing in akm's own env-injection path ever *invokes* EDITOR/VISUAL/PAGER
10
+ * with that value, so the RCE vector these three describe cannot fire from
11
+ * an injected env the way LD_PRELOAD or GIT_SSH_COMMAND can. Blocking a
12
+ * third-party install over them protects nothing while making "the operator
13
+ * legitimately wants to set their editor" the common case that eats the
14
+ * refusal. Kept as a name-level literal set (not an import of
15
+ * `commands/lint/env-key-rules.ts`) so this module stays the pure leaf its
16
+ * own doc comment promises — no new import edges into the rest of the tree.
17
+ */
18
+ const INTERACTIVE_TOOL_ENV_KEYS = new Set(["EDITOR", "VISUAL", "PAGER"]);
4
19
  /**
5
20
  * Decide whether injecting an env with the given dangerous keys is allowed,
6
- * warned, or blocked. Third-party (registry-installed) stashes hard-block;
7
- * first-party stashes warn. See rule 1 above.
21
+ * warned, or blocked. Third-party (registry-installed) stashes hard-block a
22
+ * genuine RCE-class key; first-party stashes warn. The interactive-tool
23
+ * group (see {@link INTERACTIVE_TOOL_ENV_KEYS}) only ever warns, since akm's
24
+ * own env-injection path never invokes those keys as a command. An explicit
25
+ * `--allow-insecure` (threaded through by the caller, same override
26
+ * `decideDangerousKeyInstall`'s `"warn-allow"` already honors for rule 2)
27
+ * downgrades a remaining block to a warning too — the operator is not racing
28
+ * themselves. See rule 1 above.
8
29
  *
9
30
  * @param dangerousKeys The subset of injected keys flagged as process-hijacking
10
31
  * (already filtered by the caller via `isDangerousEnvKey`).
11
32
  * @param thirdParty `true` when the env's source is a third-party stash — i.e.
12
33
  * its origin carries a `registryId`.
34
+ * @param allowInsecure `true` when the operator passed `--allow-insecure` (or
35
+ * its equivalent) for this injection. Defaults to `false`.
13
36
  */
14
37
  export function decideDangerousEnvInjection(input) {
15
38
  if (input.dangerousKeys.length === 0)
16
39
  return "allow";
17
- return input.thirdParty ? "block" : "warn";
40
+ if (!input.thirdParty)
41
+ return "warn";
42
+ if (input.allowInsecure)
43
+ return "warn";
44
+ const onlyInteractiveTool = input.dangerousKeys.every((key) => INTERACTIVE_TOOL_ENV_KEYS.has(key));
45
+ return onlyInteractiveTool ? "warn" : "block";
18
46
  }
19
47
  /**
20
48
  * Decide the baseline install stance for a freshly-installed stash's
@@ -6,6 +6,8 @@ import { isMap, isScalar, parseDocument, visit } from "yaml";
6
6
  import { cloneExecutionJson, cloneExecutionJsonObject, } from "../../execution/json.js";
7
7
  import { assertSnapshotKeys, snapshotStrictRecord } from "../../execution/record.js";
8
8
  import { cloneToolSelection, createAdapterRenderedExecutionSource, } from "../../execution/source.js";
9
+ import { UsageError } from "../errors.js";
10
+ import { warnOnce } from "../warn.js";
9
11
  function nextLine(text, start) {
10
12
  const lf = text.indexOf("\n", start);
11
13
  if (lf < 0)
@@ -13,16 +15,39 @@ function nextLine(text, start) {
13
15
  const end = lf > start && text[lf - 1] === "\r" ? lf - 1 : lf;
14
16
  return { line: text.slice(start, end), next: lf + 1 };
15
17
  }
16
- /** Strict execution-only frontmatter parser; indexing's tolerant parser is deliberately not reused. */
17
- export function parseExecutionMarkdown(raw) {
18
+ /**
19
+ * Strict execution-only frontmatter parser; indexing's tolerant parser is
20
+ * deliberately not reused.
21
+ *
22
+ * `filePath`, when given, names the offending asset in a thrown error so an
23
+ * operator (or CI) sees which file to fix instead of a bare message.
24
+ *
25
+ * A third party's markdown that genuinely cannot be parsed into a mapping
26
+ * (unterminated frontmatter, broken YAML syntax, a non-mapping document)
27
+ * throws {@link UsageError} \u2014 exit 2, an actionable usage problem, not exit
28
+ * 70 (`TypeError`'s "unclassified internal error" code, which is what a
29
+ * `throw new TypeError(...)` here used to surface as). Anchors, explicit
30
+ * tags, and non-string mapping keys are recognized-but-unsupported
31
+ * constructs rather than parse failures on their own \u2014 an anchor nobody
32
+ * aliases, or a tag `toJS` converts to a plain value, is harmless \u2014 so they
33
+ * warn once (naming the file) and fall through to conversion instead of
34
+ * being pre-emptively rejected by a second, stricter gate in front of it.
35
+ * `toJS({ maxAliasCount: 0 })` is the real, UNCHANGED safety bound against
36
+ * alias-expansion abuse \u2014 it disables alias RESOLUTION outright, so a value
37
+ * that actually references an anchor via `*name` still fails, now as a
38
+ * {@link UsageError} from this same conversion step rather than the removed
39
+ * duplicate pre-check.
40
+ */
41
+ export function parseExecutionMarkdown(raw, filePath) {
18
42
  if (typeof raw !== "string")
19
43
  throw new TypeError("execution source raw content must be a string");
44
+ const where = filePath ? ` (${filePath})` : "";
20
45
  const withoutBom = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
21
46
  const opening = nextLine(withoutBom, 0);
22
47
  if (opening.line !== "---")
23
48
  return { content: withoutBom, data: Object.freeze({}) };
24
49
  if (opening.next === withoutBom.length) {
25
- throw new TypeError("execution source has unterminated frontmatter");
50
+ throw new UsageError(`execution source${where} has unterminated frontmatter`);
26
51
  }
27
52
  let cursor = opening.next;
28
53
  let frontmatterEnd = -1;
@@ -40,18 +65,18 @@ export function parseExecutionMarkdown(raw) {
40
65
  cursor = current.next;
41
66
  }
42
67
  if (frontmatterEnd < 0 || bodyStart < 0) {
43
- throw new TypeError("execution source has unterminated frontmatter");
68
+ throw new UsageError(`execution source${where} has unterminated frontmatter`);
44
69
  }
45
70
  const yaml = withoutBom.slice(opening.next, frontmatterEnd);
46
71
  const document = parseDocument(yaml, { uniqueKeys: true });
47
72
  if (document.errors.length > 0) {
48
- throw new TypeError(`execution source has invalid YAML frontmatter: ${document.errors[0]?.message ?? "parse error"}`);
73
+ throw new UsageError(`execution source${where} has invalid YAML frontmatter: ${document.errors[0]?.message ?? "parse error"}`);
49
74
  }
50
75
  if (document.warnings.length > 0) {
51
- throw new TypeError(`execution source YAML frontmatter uses an unsupported tag or construct: ${document.warnings[0]?.message}`);
76
+ warnOnce(`execution-source:yaml-warning:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses an unsupported tag or construct: ${document.warnings[0]?.message}. Using it as parsed.`);
52
77
  }
53
78
  if (!isMap(document.contents)) {
54
- throw new TypeError("execution source YAML frontmatter must be a mapping");
79
+ throw new UsageError(`execution source${where} YAML frontmatter must be a mapping`);
55
80
  }
56
81
  let unsupported;
57
82
  visit(document, {
@@ -69,14 +94,16 @@ export function parseExecutionMarkdown(raw) {
69
94
  unsupported ??= "non-string mapping keys";
70
95
  },
71
96
  });
72
- if (unsupported)
73
- throw new TypeError(`execution source YAML frontmatter does not support ${unsupported}`);
97
+ if (unsupported) {
98
+ warnOnce(`execution-source:unsupported:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses ${unsupported}, which akm does not fully support; using the bounded conversion's result as-is.`);
99
+ }
74
100
  let root;
75
101
  try {
76
102
  root = document.toJS({ maxAliasCount: 0 });
77
103
  }
78
104
  catch (cause) {
79
- throw new TypeError("execution source YAML frontmatter could not be converted safely", { cause });
105
+ const detail = cause instanceof Error ? cause.message : String(cause);
106
+ throw new UsageError(`execution source${where} YAML frontmatter could not be converted safely: ${detail}`);
80
107
  }
81
108
  const data = cloneExecutionJsonObject(root, "execution source YAML frontmatter");
82
109
  return { content: withoutBom.slice(bodyStart), data: Object.freeze(data) };
@@ -279,7 +306,8 @@ export function renderMarkdownExecutionSource(input) {
279
306
  const kind = snapshots.input.kind;
280
307
  if (kind !== "command" && kind !== "persona")
281
308
  throw new TypeError("adapter execution source.kind is invalid");
282
- const parsed = parseExecutionMarkdown(raw);
309
+ const identityFile = snapshots.identity.file;
310
+ const parsed = parseExecutionMarkdown(raw, typeof identityFile === "string" ? identityFile : undefined);
283
311
  const hasDefaults = Object.hasOwn(snapshots.input, "defaults");
284
312
  const defaultsProjection = snapshots.input.defaults;
285
313
  const defaults = typeof defaultsProjection === "function" ? defaultsProjection(parsed.data) : defaultsProjection;
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import { isWithin } from "../common.js";
21
22
  import { UsageError } from "../errors.js";
22
23
  /** Root-relative directory holding a stash's meta docs. */
23
24
  export const META_DIR = ".meta";
@@ -113,7 +114,7 @@ export function readMetaFile(sourceRoot, name) {
113
114
  return null;
114
115
  let fd;
115
116
  try {
116
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
117
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY);
117
118
  const opened = fs.fstatSync(fd);
118
119
  if (!opened.isFile())
119
120
  throw metaPathEscape();
@@ -141,60 +142,25 @@ export function readMetaFile(sourceRoot, name) {
141
142
  }
142
143
  }
143
144
  function isSafeRegularMetaFile(sourceRoot, metaRoot, filePath) {
144
- const sourceRootReal = realPathOrNull(sourceRoot);
145
- if (!sourceRootReal)
146
- return false;
147
- const metaStat = lstatOrNull(metaRoot);
148
- if (!metaStat)
149
- return false;
150
- if (metaStat.isSymbolicLink())
151
- throw metaPathEscape();
152
- if (!metaStat.isDirectory())
153
- return false;
154
145
  const relative = path.relative(metaRoot, filePath);
155
146
  if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
156
147
  throw metaPathEscape();
157
148
  }
158
- let current = metaRoot;
159
- for (const segment of relative.split(path.sep)) {
160
- current = path.join(current, segment);
161
- const stat = lstatOrNull(current);
162
- if (!stat)
163
- return false;
164
- if (stat.isSymbolicLink())
165
- throw metaPathEscape();
166
- }
167
- const finalStat = lstatOrNull(filePath);
149
+ const finalStat = statOrNull(filePath);
168
150
  if (!finalStat?.isFile())
169
151
  return false;
170
- const metaRootReal = realPathOrNull(metaRoot);
171
- const fileReal = realPathOrNull(filePath);
172
- if (!metaRootReal || !fileReal)
173
- return false;
174
- if (!isWithin(sourceRootReal, metaRootReal) || !isWithin(metaRootReal, fileReal))
152
+ if (!isWithin(filePath, sourceRoot))
175
153
  throw metaPathEscape();
176
154
  return true;
177
155
  }
178
- function lstatOrNull(filePath) {
156
+ function statOrNull(filePath) {
179
157
  try {
180
- return fs.lstatSync(filePath);
158
+ return fs.statSync(filePath);
181
159
  }
182
160
  catch {
183
161
  return null;
184
162
  }
185
163
  }
186
- function realPathOrNull(filePath) {
187
- try {
188
- return fs.realpathSync(filePath);
189
- }
190
- catch {
191
- return null;
192
- }
193
- }
194
- function isWithin(root, candidate) {
195
- const relative = path.relative(root, candidate);
196
- return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
197
- }
198
164
  function metaPathEscape() {
199
- return new UsageError("Meta doc must be a regular file inside the stash .meta directory; symlinks are refused.", "PATH_ESCAPE_VIOLATION");
165
+ return new UsageError("Meta doc must resolve to a regular file inside the stash .meta directory, not one that escapes it.", "PATH_ESCAPE_VIOLATION");
200
166
  }
@@ -46,6 +46,12 @@ function ensureUniqueId(preferred, sourcePath, used) {
46
46
  n++;
47
47
  return `${suffixed}-${n}`;
48
48
  }
49
- function shortHash(input) {
49
+ /**
50
+ * First 8 hex chars of `input`'s sha256 — deterministic and short enough to
51
+ * suffix a slug or a path key. Exported so callers needing the same
52
+ * short-hash-of-a-resolved-path primitive (e.g. `getStashStateKey` in
53
+ * `paths.ts`) don't grow their own duplicate.
54
+ */
55
+ export function shortHash(input) {
50
56
  return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
51
57
  }