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

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 (111) hide show
  1. package/CHANGELOG.md +162 -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 +5 -0
  8. package/dist/cli/unknown-flags.js +226 -0
  9. package/dist/cli.js +19 -35
  10. package/dist/commands/agent/agent-dispatch.js +7 -3
  11. package/dist/commands/agent/contribute-cli.js +15 -3
  12. package/dist/commands/feedback-cli.js +6 -3
  13. package/dist/commands/health/checks.js +6 -3
  14. package/dist/commands/improve/collapse-detector.js +2 -3
  15. package/dist/commands/improve/reflect.js +5 -3
  16. package/dist/commands/lint/base-linter.js +4 -16
  17. package/dist/commands/lint/index.js +13 -13
  18. package/dist/commands/log.js +6 -1
  19. package/dist/commands/migration-tool.js +4 -5
  20. package/dist/commands/observability-cli.js +1 -1
  21. package/dist/commands/proposal/propose.js +5 -3
  22. package/dist/commands/proposal/repository.js +5 -5
  23. package/dist/commands/read/knowledge.js +2 -0
  24. package/dist/commands/registry-cli.js +5 -3
  25. package/dist/commands/sources/add-cli.js +6 -6
  26. package/dist/commands/sources/init.js +1 -1
  27. package/dist/commands/sources/self-update.js +30 -7
  28. package/dist/commands/sources/source-add.js +17 -2
  29. package/dist/commands/tasks/tasks.js +10 -10
  30. package/dist/commands/workflow-cli.js +133 -259
  31. package/dist/core/adapter/adapters/akm-lint.js +17 -13
  32. package/dist/core/adapter/adapters/akm-task-adapter.js +14 -11
  33. package/dist/core/asset/akm-markdown.js +41 -8
  34. package/dist/core/asset/frontmatter.js +22 -0
  35. package/dist/core/asset/resolve-ref.js +23 -3
  36. package/dist/core/common.js +45 -2
  37. package/dist/core/config/config-schema.js +8 -0
  38. package/dist/core/config/schema/experimental.js +0 -17
  39. package/dist/core/config/schema/sources-bundles.js +11 -0
  40. package/dist/core/config/schema/workflow.js +3 -3
  41. package/dist/core/errors.js +5 -1
  42. package/dist/core/logs-db.js +2 -1
  43. package/dist/core/parse.js +4 -1
  44. package/dist/core/state/migrations.js +11 -14
  45. package/dist/core/state-db.js +4 -6
  46. package/dist/core/subprocess.js +6 -4
  47. package/dist/core/type-presentation.js +1 -1
  48. package/dist/indexer/indexer.js +16 -1
  49. package/dist/indexer/search/search-source.js +1 -1
  50. package/dist/indexer/walk/matchers.js +3 -1
  51. package/dist/integrations/agent/config.js +2 -2
  52. package/dist/integrations/agent/detect.js +49 -19
  53. package/dist/integrations/agent/engine-fallback.js +76 -0
  54. package/dist/integrations/agent/profiles.js +14 -0
  55. package/dist/integrations/agent/spawn.js +1 -2
  56. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
  57. package/dist/integrations/lockfile.js +11 -56
  58. package/dist/output/shapes/passthrough.js +0 -6
  59. package/dist/output/text/command-format.js +3 -3
  60. package/dist/output/text/helpers.js +1 -1
  61. package/dist/output/text/show-directives.js +4 -6
  62. package/dist/output/text/workflow-format.js +15 -181
  63. package/dist/output/text/workflow.js +1 -6
  64. package/dist/scripts/akm-migrate-node.js +58666 -0
  65. package/dist/scripts/akm-migrate.js +33976 -11498
  66. package/dist/setup/detect.js +42 -15
  67. package/dist/setup/setup.js +1 -1
  68. package/dist/sources/providers/git-stash.js +4 -2
  69. package/dist/sources/providers/website.js +5 -0
  70. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  71. package/dist/sources/snapshot-fetchers/content-extract.js +370 -0
  72. package/dist/sources/snapshot-fetchers/fetcher-util.js +40 -0
  73. package/dist/sources/snapshot-fetchers/host-guard.js +199 -0
  74. package/dist/sources/snapshot-fetchers/registry.js +10 -1
  75. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  76. package/dist/sources/snapshot-fetchers/rss.js +279 -0
  77. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  78. package/dist/sources/snapshot-fetchers/website-ingest.js +488 -257
  79. package/dist/sources/snapshot-fetchers/x.js +193 -0
  80. package/dist/storage/engines/sqlite-migrations.js +22 -107
  81. package/dist/storage/repositories/workflow-runs-repository.js +9 -23
  82. package/dist/tasks/runner.js +43 -19
  83. package/dist/tasks/schema.js +24 -1
  84. package/dist/tasks/validator.js +6 -2
  85. package/dist/workflows/exec/frozen-judge.js +28 -2
  86. package/dist/workflows/exec/native-executor.js +44 -28
  87. package/dist/workflows/exec/param-secrets.js +9 -9
  88. package/dist/workflows/exec/run-workflow.js +122 -60
  89. package/dist/workflows/exec/step-work.js +64 -175
  90. package/dist/workflows/exec/unit-dispatch.js +4 -0
  91. package/dist/workflows/ir/compile.js +2 -2
  92. package/dist/workflows/ir/freeze.js +29 -9
  93. package/dist/workflows/ir/params.js +135 -11
  94. package/dist/workflows/ir/plan-hash.js +1 -1
  95. package/dist/workflows/ir/schema.js +6 -2
  96. package/dist/workflows/renderer.js +2 -2
  97. package/dist/workflows/runtime/checkin.js +3 -3
  98. package/dist/workflows/runtime/runs.js +72 -92
  99. package/dist/workflows/runtime/unit-checkin.js +1 -1
  100. package/dist/workflows/runtime/unit-phases.js +2 -2
  101. package/dist/workflows/validate-summary.js +30 -14
  102. package/docs/migration/release-notes/0.9.0.md +31 -4
  103. package/docs/migration/v0.8-to-v0.9.md +70 -100
  104. package/docs/reference/data-and-telemetry.md +5 -5
  105. package/package.json +7 -3
  106. package/schemas/akm-config.json +24 -6
  107. package/schemas/akm-workflow.json +1 -1
  108. package/dist/workflows/cli.js +0 -33
  109. package/dist/workflows/exec/brief.js +0 -450
  110. package/dist/workflows/exec/report.js +0 -1460
  111. package/dist/workflows/exec/workflow-engine-gate.js +0 -67
@@ -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) &&
@@ -26,22 +26,5 @@ export const ExperimentalConfigSchema = z
26
26
  * purpose, and it has its own `sync.push: false` / `--no-push` controls.
27
27
  */
28
28
  improveAutonomy: z.boolean().optional(),
29
- /**
30
- * Allow the `akm workflow` native engine to run (Q-05).
31
- *
32
- * OFF by default. Gates `akm workflow run` (the native step-execution
33
- * engine) and `akm workflow brief`/`report` (the harness-neutral driver
34
- * protocol).
35
- *
36
- * Deliberately NOT gated: authoring/linting the unified markdown format
37
- * and the manual workflow CLI contract (`start`/`next`/`complete`/
38
- * `status`/`list`/`create`/`resume`/`abandon`) remain stable regardless of
39
- * this key.
40
- *
41
- * Unlike `improveAutonomy`, a gated call here REFUSES outright rather than
42
- * degrading: a workflow step either executes or it does not, so there is
43
- * no safe partial-execution fallback to fall back to.
44
- */
45
- workflowEngine: z.boolean().optional(),
46
29
  })
47
30
  .passthrough();
@@ -80,6 +80,17 @@ const BundleWebsiteDescriptorSchema = z
80
80
  refresh: z.string().min(1).optional(),
81
81
  maxPages: positiveInt.optional(),
82
82
  maxDepth: positiveInt.optional(),
83
+ // Default true: crawl-scoped robots.txt compliance (Disallow/Crawl-delay
84
+ // for the akm/akm-cli product tokens or "*"). See
85
+ // src/sources/snapshot-fetchers/robots.ts. Opt out with `false` to
86
+ // restore pre-P1 behavior exactly (no /robots.txt request at all).
87
+ respectRobots: z.boolean().optional(),
88
+ // Hard wall-clock cap on the whole crawl, in milliseconds. Defaults to
89
+ // 10 minutes. Unlike a between-page check, this aborts work already in
90
+ // flight — including a `Retry-After` sleep, which a server can otherwise
91
+ // make arbitrarily long. Set to 0 to disable the cap entirely for a
92
+ // deliberately long-running crawl.
93
+ crawlTimeoutMs: z.number().int().min(0).optional(),
83
94
  })
84
95
  .passthrough();
85
96
  /** One component of a bundle (spec §10.1). Single-entry, transitional. */
@@ -6,7 +6,7 @@
6
6
  * `config-schema.ts` monolith — no behavior change.
7
7
  */
8
8
  import { z } from "zod";
9
- import { positiveInt } from "./primitives.js";
9
+ import { engineName, positiveInt } from "./primitives.js";
10
10
  // ── Workflow engine ─────────────────────────────────────────────────────────
11
11
  /**
12
12
  * Workflow-engine settings (`workflow`).
@@ -19,11 +19,11 @@ import { positiveInt } from "./primitives.js";
19
19
  * `[1, WORKFLOW_MAX_CONCURRENCY_CEILING]` (64). Values above the ceiling
20
20
  * are clamped, not rejected, so a config shared across machines with wildly
21
21
  * different core counts never hard-fails validation.
22
- * The R3 brief/report driver surface does NOT consult this — drivers own their
23
- * own parallelism (the engine only caps native dispatch).
24
22
  */
25
23
  export const WorkflowConfigSchema = z
26
24
  .object({
27
25
  maxConcurrency: positiveInt.optional(),
26
+ /** Named LLM or agent engine frozen into every criteria-bearing gate. */
27
+ judgeEngine: engineName.optional(),
28
28
  })
29
29
  .passthrough();
@@ -15,7 +15,6 @@ const CONFIG_HINTS = {
15
15
  SETUP_TMP_STASH_REFUSED: "Use a persistent directory, or set AKM_FORCE_SETUP_TMP_STASH=1 to opt in to a sandboxed setup (setup also pre-sets AKM_BUNDLE_DIR so config and cache writes auto-isolate into $stashDir/.akm/ — host config is preserved).",
16
16
  UNSAFE_STASH_DIR: "Choose a path inside your home directory (e.g. ~/akm) or another empty workspace. The bundle directory cannot be the filesystem root, your home directory itself, or a sensitive system path like /etc, /var, ~/.config, or ~/.ssh.",
17
17
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
18
- WORKFLOW_ENGINE_NOT_ENABLED: "Run `akm config set experimental.workflowEngine true` to enable it.",
19
18
  };
20
19
  /** Default hint for each UsageError code. */
21
20
  const USAGE_HINTS = {
@@ -29,12 +28,17 @@ const USAGE_HINTS = {
29
28
  TARGET_NOT_UPDATABLE: "Run `akm bundle list` to view your sources, then retry with one of those values.",
30
29
  MISSING_REQUIRED_ARGUMENT: "Refs use the form [bundle//]conceptId, e.g. `akm show knowledge/guide.md` or `akm show skills/deploy`.",
31
30
  UNKNOWN_COMMAND: "Run `akm --help` to see available commands.",
31
+ UNKNOWN_FLAG: "Run the command with `--help` to see its accepted flags.",
32
32
  };
33
33
  /** Default hint for each NotFoundError code. */
34
34
  const NOT_FOUND_HINTS = {
35
35
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
36
36
  SOURCE_NOT_FOUND: "Run `akm bundle list` to view your sources, then retry with one of those values.",
37
37
  WORKFLOW_NOT_FOUND: "Run `akm workflow list --active` to see runs.",
38
+ // A proposal is addressed by id or ref, never by path — reusing
39
+ // FILE_NOT_FOUND here handed users "check the path exists and is readable"
40
+ // for a mistyped id, which points at the wrong thing entirely.
41
+ PROPOSAL_NOT_FOUND: "Run `akm proposal list` to see pending proposals and their ids.",
38
42
  FILE_NOT_FOUND: "Check the path exists and is readable.",
39
43
  };
40
44
  /**
@@ -38,7 +38,7 @@
38
38
  * @module logs-db
39
39
  */
40
40
  import path from "node:path";
41
- import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
41
+ import { assertMigrationRegistry, runMigrations as runSqliteMigrations, } from "../storage/engines/sqlite-migrations.js";
42
42
  import { openManagedDatabase } from "../storage/managed-db.js";
43
43
  import { getDataDir } from "./paths.js";
44
44
  // ── Path helper ──────────────────────────────────────────────────────────────
@@ -130,6 +130,7 @@ const MIGRATIONS = [
130
130
  `,
131
131
  },
132
132
  ];
133
+ assertMigrationRegistry(MIGRATIONS);
133
134
  /**
134
135
  * Apply every pending migration. Called automatically by
135
136
  * {@link openLogsDatabase}; exported for the same test seams state-db exposes.
@@ -23,7 +23,10 @@
23
23
  * models). Also strips leading/trailing whitespace.
24
24
  */
25
25
  export function stripThinkBlocks(raw) {
26
- return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
26
+ return raw
27
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
28
+ .replace(/^[\s\S]*?<\/think>/i, "")
29
+ .trim();
27
30
  }
28
31
  /**
29
32
  * Strips markdown code fences (``` or ~~~, with optional language tag).
@@ -1,7 +1,7 @@
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 { runMigrations as runSqliteMigrations } from "../../storage/engines/sqlite-migrations.js";
4
+ import { assertMigrationRegistry, runMigrations as runSqliteMigrations, } from "../../storage/engines/sqlite-migrations.js";
5
5
  export const STATE_MIGRATIONS = [
6
6
  // ── Migration 001 — initial schema ──────────────────────────────────────────
7
7
  {
@@ -59,7 +59,7 @@ export const STATE_MIGRATIONS = [
59
59
  -- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
60
60
  -- stash_dir TEXT — absolute stash root; multi-stash installs need
61
61
  -- this to partition proposal lists per stash.
62
- -- ref TEXT — target asset ref (e.g. "lesson:alpha");
62
+ -- ref TEXT — target asset ref (e.g. "lessons/alpha");
63
63
  -- indexed for ref-scoped queue views.
64
64
  -- status TEXT — "pending" | "accepted" | "rejected"; indexed
65
65
  -- so pending-queue queries are fast.
@@ -76,7 +76,7 @@ export const STATE_MIGRATIONS = [
76
76
  -- metadata_json TEXT — JSON object for future proposal fields.
77
77
  -- Current fields stored here: sourceRun,
78
78
  -- review, confidence, gateDecision (#577),
79
- -- backupContent, eligibilitySource.
79
+ -- backupContent.
80
80
  --
81
81
  -- ADD COLUMN extension points (future migrations):
82
82
  -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
@@ -353,9 +353,9 @@ export const STATE_MIGRATIONS = [
353
353
  // (`scripts/akm-migrate/migrate/legacy/proposal-fs-import.ts`, wired through
354
354
  // `akm-migrate apply`),
355
355
  // whose idempotency is INSERT OR IGNORE on the proposal UUID plus migrate-apply's
356
- // own journal — it no longer reads or writes this ledger. The CREATE TABLE
357
- // stays because the migration registry is APPEND-ONLY and checksum-sealed:
358
- // removing a released fragment would make the schema_migrations ledger of an
356
+ // incomplete sentinel — it no longer reads or writes this ledger. The CREATE TABLE
357
+ // stays because migration IDs are append-only: removing a released migration
358
+ // would make the schema_migrations ledger of an
359
359
  // already-migrated rc database stop being an exact ordered prefix, and the
360
360
  // runner would refuse to open it. The empty table is harmless.
361
361
  //
@@ -833,12 +833,11 @@ export const STATE_MIGRATIONS = [
833
833
  //
834
834
  // The state.db half of the three-DB merge (plan §3.2/§8, normative §11.4,
835
835
  // chunk-8 cutover design §1). This migration is PURE,
836
- // SEALABLE, IDEMPOTENT DDL ONLY — `CREATE TABLE IF NOT EXISTS` (+ indexes),
836
+ // IDEMPOTENT DDL ONLY — `CREATE TABLE IF NOT EXISTS` (+ indexes),
837
837
  // never a DROP or a data move. The actual data movement (the workflow.db merge,
838
838
  // the usage_events rescue from index.db, the full old-ref→item_ref re-key, and
839
- // the workflow.db delete / index.db quarantine) is CODE — a journaled step of
840
- // the migrate-apply flow (`scripts/akm-migrate/config-migrate.ts`
841
- // `cutover-applied` phase), driven by
839
+ // the workflow.db delete / index.db quarantine) is idempotent migrate-apply
840
+ // code driven by
842
841
  // `scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`. See the no-DROP
843
842
  // contract carve-out note in `src/core/state-db.ts`.
844
843
  //
@@ -985,6 +984,7 @@ export const STATE_MIGRATIONS = [
985
984
  `,
986
985
  },
987
986
  ];
987
+ assertMigrationRegistry(STATE_MIGRATIONS);
988
988
  /**
989
989
  * Apply every pending migration in a single transaction per migration.
990
990
  *
@@ -994,8 +994,5 @@ export const STATE_MIGRATIONS = [
994
994
  * Called automatically by `openStateDatabase()`.
995
995
  */
996
996
  export function runMigrations(db, options) {
997
- runSqliteMigrations(db, STATE_MIGRATIONS, {
998
- applyPending: options?.applyPending,
999
- generationMarker: options?.generationMarker,
1000
- });
997
+ runSqliteMigrations(db, STATE_MIGRATIONS, { applyPending: options?.applyPending });
1001
998
  }
@@ -45,12 +45,10 @@
45
45
  * merge-target tables at their final shape. The one-time, filesystem-derived,
46
46
  * fail-closed DATA movement (the workflow.db merge, the usage_events rescue, the
47
47
  * full old-ref→item_ref re-key, and the workflow.db unlink / index.db quarantine
48
- * rename) is deliberately NOT a sealed SQL migration body: it is a journaled step
49
- * of the migrate-apply coordinator (`scripts/akm-migrate/config-migrate.ts`
50
- * `cutover-applied` phase →
51
- * `scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`). So the no-DROP
52
- * contract here is intact — the physical workflow.db deletion happens outside
53
- * the ledger DDL, under the backup-verified-restorable fail-closed gate.
48
+ * rename) runs as idempotent code in the migrate-apply coordinator
49
+ * (`scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`). So the no-DROP
50
+ * contract here is intact: physical workflow.db deletion happens outside the
51
+ * ledger DDL, after a verified backup and committed data transaction.
54
52
  *
55
53
  * ## Schema design: indexed columns vs. metadata_json
56
54
  *
@@ -148,6 +148,7 @@ export async function readStream(stream, opts) {
148
148
  }
149
149
  }
150
150
  const EMPTY_READ = { text: "", timedOut: false };
151
+ const UNBOUNDED_STREAM_READ_SAFETY_MS = 60 * 60 * 1000;
151
152
  function toError(err) {
152
153
  return err instanceof Error ? err : new Error(String(err));
153
154
  }
@@ -238,10 +239,11 @@ export async function runManagedSubprocess(cmd, opts) {
238
239
  else
239
240
  abortSignal.addEventListener("abort", onAbort, { once: true });
240
241
  }
241
- // Stream-drain timeout: the wall budget plus a 2 s grace, or 30 s when there
242
- // is no kill timer. Ensures the caller never hangs past the budget even if a
243
- // killed child leaves a pipe write-end open in a background thread.
244
- const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : 30_000;
242
+ // Stream-drain timeout: the wall budget plus a 2 s grace, or a one-hour
243
+ // safety bound when execution itself is unbounded. This timer starts with
244
+ // capture, so the null-timeout path must not impose a short hidden deadline
245
+ // on an otherwise healthy long-running process.
246
+ const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : UNBOUNDED_STREAM_READ_SAFETY_MS;
245
247
  const stdoutPromise = capture
246
248
  ? readStream(proc.stdout ?? null, {
247
249
  timeoutMs: streamDrainTimeoutMs,
@@ -38,7 +38,7 @@ function shellQuote(value) {
38
38
  }
39
39
  /** Reproduced from `output/renderers.ts#buildWorkflowAction` — the one function-valued `ACTION_BUILDERS.workflow` entry. */
40
40
  function buildWorkflowAction(ref) {
41
- return `Resume the active run or start a new run with \`akm workflow next ${shellQuote(ref)}\`.`;
41
+ return `Start or resume execution with \`akm workflow run ${shellQuote(ref)}\`.`;
42
42
  }
43
43
  /**
44
44
  * Exhaustive over {@link KnownType} — a HAND-WRITTEN literal, not derived
@@ -419,12 +419,27 @@ async function akmIndexReal(options) {
419
419
  // Load config and resolve all stash sources
420
420
  const { loadConfig, mutateConfig } = await import("../core/config/config.js");
421
421
  let config = loadConfig();
422
+ // Durable state must be runtime-compatible before source hydration,
423
+ // adapter persistence, or index.db creation can mutate the installation.
424
+ onProgress({ phase: "preflight", message: "Validating durable state." });
425
+ withStateDb(() => undefined);
422
426
  // Ensure git stash caches are extracted before resolving stash dirs,
423
427
  // so their content directories exist on disk for the walker to discover.
424
428
  const sourceCacheStart = Date.now();
425
429
  onProgress({ phase: "preflight", message: "Hydrating source caches." });
426
430
  const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
427
- await ensureSourceCaches(config, { force: full, materialize: options.hydrateSources !== false });
431
+ // Inject the store-backed secret resolver from here — a composition root
432
+ // ABOVE the provider/fetcher import cycle (this module reaches
433
+ // search-source only via dynamic import). This is what lets a website
434
+ // source's X fetcher resolve `secrets/x-bearer-token` during
435
+ // bundle-update / hydrate, not just from the command-layer URL-ingest
436
+ // path. `secret-seam` is imported here, never from inside the cycle.
437
+ const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
438
+ await ensureSourceCaches(config, {
439
+ force: full,
440
+ materialize: options.hydrateSources !== false,
441
+ secrets: storeSecretResolver,
442
+ });
428
443
  const sourceCacheEnd = Date.now();
429
444
  const allSourceEntries = resolveSourceEntries(stashDir, config);
430
445
  const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
@@ -304,7 +304,7 @@ export async function ensureSourceCaches(config, options) {
304
304
  continue;
305
305
  }
306
306
  try {
307
- await provider.sync({ force });
307
+ await provider.sync({ force, secrets: options?.secrets });
308
308
  }
309
309
  catch (err) {
310
310
  warn(`Warning: failed to refresh ${provider.kind} source "${provider.name}": ${err instanceof Error ? err.message : String(err)}`);