akm-cli 0.9.7 → 0.9.8-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/improve-metrics.js +8 -34
  5. package/dist/commands/health/windows.js +0 -4
  6. package/dist/commands/health.js +1 -35
  7. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  8. package/dist/commands/improve/extract.js +36 -32
  9. package/dist/commands/improve/memory/memory-belief.js +15 -5
  10. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  11. package/dist/commands/improve/memory/memory-improve.js +7 -19
  12. package/dist/commands/improve/preparation.js +52 -36
  13. package/dist/commands/improve/reflect.js +13 -17
  14. package/dist/commands/lint/base-linter.js +42 -2
  15. package/dist/commands/lint/index.js +4 -5
  16. package/dist/commands/migrate/config-extra-params.js +61 -0
  17. package/dist/commands/migrate/dead-residue.js +113 -0
  18. package/dist/commands/migrate/stale-txn.js +49 -0
  19. package/dist/commands/migrate-cli.js +42 -1
  20. package/dist/commands/proposal/proposal.js +1 -21
  21. package/dist/commands/proposal/repository.js +0 -4
  22. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  23. package/dist/commands/read/curate.js +4 -18
  24. package/dist/commands/read/show.js +2 -1
  25. package/dist/commands/sources/installed-stashes.js +5 -1
  26. package/dist/commands/sources/self-update.js +38 -1
  27. package/dist/commands/sources/sources-cli.js +17 -1
  28. package/dist/commands/tasks/tasks.js +0 -8
  29. package/dist/commands/url-checker.js +20 -28
  30. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  31. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  32. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  33. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  34. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  36. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  37. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  38. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  39. package/dist/core/adapter/adapters/shared.js +2 -0
  40. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  41. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  42. package/dist/core/adapter/validate-context.js +1 -3
  43. package/dist/core/asset/asset-placement.js +1 -3
  44. package/dist/core/asset/frontmatter.js +96 -0
  45. package/dist/core/common.js +66 -2
  46. package/dist/core/config/config-walker.js +6 -10
  47. package/dist/core/config/config.js +11 -8
  48. package/dist/core/extra-params.js +17 -13
  49. package/dist/core/improve-result.js +1 -3
  50. package/dist/core/json-schema.js +9 -11
  51. package/dist/core/state/migrations.js +52 -2
  52. package/dist/core/state-db.js +2 -1
  53. package/dist/execution/executable-identity.js +1 -3
  54. package/dist/execution/guarded-source.js +1 -6
  55. package/dist/indexer/bundle-identity-guard.js +6 -1
  56. package/dist/indexer/db/graph-db.js +139 -154
  57. package/dist/indexer/ensure-index.js +11 -19
  58. package/dist/indexer/graph/graph-boost.js +23 -34
  59. package/dist/indexer/graph/graph-extraction.js +12 -2
  60. package/dist/indexer/indexer.js +1 -1
  61. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  62. package/dist/indexer/passes/memory-inference.js +7 -2
  63. package/dist/indexer/scan/drain-dir.js +2 -4
  64. package/dist/indexer/search/db-search.js +3 -3
  65. package/dist/indexer/search/fts-query.js +10 -15
  66. package/dist/indexer/search/search-source.js +0 -13
  67. package/dist/indexer/usage/usage-events.js +9 -1
  68. package/dist/indexer/walk/walker.js +11 -6
  69. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  70. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  71. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  72. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  73. package/dist/integrations/harnesses/index.js +0 -4
  74. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  75. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  76. package/dist/integrations/lockfile.js +0 -14
  77. package/dist/integrations/session-logs/index.js +0 -81
  78. package/dist/llm/client.js +0 -12
  79. package/dist/llm/memory-infer.js +1 -3
  80. package/dist/llm/usage-telemetry.js +1 -3
  81. package/dist/registry/create-provider-registry.js +4 -0
  82. package/dist/registry/factory.js +4 -0
  83. package/dist/registry/resolve.js +23 -8
  84. package/dist/runtime.js +0 -12
  85. package/dist/scripts/akm-migrate-node.js +142 -244
  86. package/dist/scripts/akm-migrate.js +142 -244
  87. package/dist/setup/setup.js +6 -12
  88. package/dist/sources/providers/git-install.js +7 -2
  89. package/dist/sources/providers/tar-utils.js +1 -7
  90. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  91. package/dist/storage/managed-db.js +20 -7
  92. package/dist/storage/repositories/events-repository.js +0 -81
  93. package/dist/storage/repositories/index-entries-repository.js +0 -17
  94. package/dist/storage/repositories/index-fts-repository.js +24 -30
  95. package/dist/storage/repositories/index-utility-repository.js +0 -57
  96. package/dist/storage/repositories/index-vec-repository.js +25 -27
  97. package/dist/storage/repositories/task-history-repository.js +9 -3
  98. package/dist/tasks/backends/launchd.js +8 -18
  99. package/dist/tasks/run/task-history.js +21 -31
  100. package/dist/tasks/scheduler-sync.js +1 -6
  101. package/dist/tasks/source/bounded-document.js +1 -14
  102. package/dist/workflows/exec/child-workflow.js +1 -1
  103. package/dist/workflows/exec/native-executor.js +2 -2
  104. package/dist/workflows/exec/step-work.js +5 -17
  105. package/dist/workflows/exec/worktree.js +40 -6
  106. package/dist/workflows/freeze/task-bindings.js +2 -4
  107. package/dist/workflows/ir/compile.js +3 -14
  108. package/dist/workflows/ir/schema-v4.js +4 -6
  109. package/dist/workflows/ir/schema.js +2 -5
  110. package/dist/workflows/parser.js +23 -26
  111. package/dist/workflows/source-files.js +8 -13
  112. package/dist/workflows/source-ir/schema.js +1 -19
  113. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  114. package/docs/reference/cli.md +2 -0
  115. package/docs/reference/workflow-schema.md +24 -0
  116. package/package.json +2 -5
  117. package/dist/workflows/source-ir/compare.js +0 -17
@@ -49,21 +49,15 @@
49
49
  import path from "node:path";
50
50
  import { createAdapterExtensions, } from "../../../execution/source.js";
51
51
  import { parseFrontmatter } from "../../asset/frontmatter.js";
52
+ import { toPosix } from "../../common.js";
52
53
  import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js";
53
54
  import { skillDirectoryDiagnostics } from "./akm-lint.js";
54
- import { hashContent, nonEmptyString, readTags, runBaseValidateChecks } from "./shared.js";
55
- /** OKF reserved structural files (D-R6) — excluded at every depth, case-insensitive. */
56
- const RESERVED_FILES = new Set(["index.md", "log.md"]);
57
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
58
- const MAX_CONTENT_CHARS = 100_000;
55
+ import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
59
56
  /** The canonical (plural) subdir spellings writes normalize to (open-question-6). */
60
57
  const CANONICAL_COMMAND_DIR = "commands";
61
58
  const CANONICAL_AGENT_DIR = "agents";
62
59
  const CANONICAL_SKILL_DIR = "skills";
63
60
  const SKILL_MANIFEST = "SKILL.md";
64
- function toPosix(p) {
65
- return p.replace(/\\/g, "/");
66
- }
67
61
  function isReserved(base) {
68
62
  return RESERVED_FILES.has(base.toLowerCase());
69
63
  }
@@ -122,7 +116,7 @@ export function recognizeToolDir(layout, c, file) {
122
116
  adapterId: layout.adapterId,
123
117
  type: cls.type,
124
118
  name,
125
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
119
+ content: body,
126
120
  };
127
121
  if (cls.type === "instruction")
128
122
  doc.ownsPresentation = true;
@@ -28,6 +28,7 @@ import fs from "node:fs";
28
28
  import path from "node:path";
29
29
  import { parse as parseYaml } from "yaml";
30
30
  import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js";
31
+ import { toPosix } from "../../common.js";
31
32
  import { hashContent, nonEmptyString, runBaseValidateChecks } from "./shared.js";
32
33
  /** A snapshot bundle is single-component; its one component is `main`. */
33
34
  const COMPONENT_ID = "main";
@@ -37,11 +38,6 @@ const PAGES_PREFIX = "stash/knowledge/";
37
38
  const MANIFEST_FILE = "manifest.json";
38
39
  /** The tag the snapshot writer stamps on every crawled page. */
39
40
  const WEBSITE_TAG = "website";
40
- /** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
41
- const MAX_CONTENT_CHARS = 100_000;
42
- function toPosix(p) {
43
- return p.replace(/\\/g, "/");
44
- }
45
41
  function parseSnapshotFrontmatter(raw) {
46
42
  const out = { tags: [] };
47
43
  const block = parseFrontmatterBlock(raw);
@@ -98,7 +94,7 @@ function recognize(c, file) {
98
94
  ownsPresentation: true,
99
95
  type: "website",
100
96
  name,
101
- content: body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) : body,
97
+ content: body,
102
98
  };
103
99
  if (fm.description !== undefined)
104
100
  doc.description = fm.description;
@@ -48,9 +48,7 @@ import fs from "node:fs";
48
48
  import path from "node:path";
49
49
  import { assetPathForName, stashDirFor } from "../asset/asset-placement.js";
50
50
  import { typeNameFromConceptId } from "../asset/resolve-ref.js";
51
- function toPosix(p) {
52
- return p.replace(/\\/g, "/");
53
- }
51
+ import { toPosix } from "../common.js";
54
52
  /** Build the overlay map, keyed by POSIX path relative to `root`. */
55
53
  function buildOverlay(root, changes) {
56
54
  const overlay = new Map();
@@ -25,10 +25,8 @@
25
25
  */
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
+ import { toPosix } from "../common.js";
28
29
  import { DERIVED_SUFFIX, SCRIPT_EXTENSIONS, WORKFLOW_EXTENSIONS } from "../recognition-util.js";
29
- function toPosix(input) {
30
- return input.replace(/\\/g, "/");
31
- }
32
30
  const workflowSpec = {
33
31
  isRelevantFile: (fileName) => WORKFLOW_EXTENSIONS.includes(path.extname(fileName).toLowerCase()),
34
32
  toCanonicalName: (typeRoot, filePath) => {
@@ -323,6 +323,102 @@ export function removeFrontmatterListValues(raw, key, values) {
323
323
  }
324
324
  return changed ? out.join("\n") : null;
325
325
  }
326
+ /**
327
+ * Rewrite specific VALUES in one frontmatter list key to new spellings,
328
+ * preserving every other byte — the rename counterpart to
329
+ * {@link removeFrontmatterListValues}, used by `akm lint --fix` to migrate
330
+ * retired `type:slug` xref values (`xrefs:`/`supersededBy:`/`contradictedBy:`)
331
+ * to their conceptId form once resolution confirms the rewritten spelling
332
+ * still points at a real asset.
333
+ *
334
+ * Handles the same three spellings a belief channel appears in: a block
335
+ * sequence, an inline flow, and a bare scalar. `replacements` maps an
336
+ * unquoted OLD value to its NEW value; a value not present in the map is left
337
+ * untouched byte-for-byte (including any quoting it had).
338
+ *
339
+ * Returns the rewritten source, or `null` when `raw` has no well-formed
340
+ * frontmatter block or nothing matched, so the caller can leave the file
341
+ * untouched. Deliberately source-preserving, like its sibling.
342
+ */
343
+ export function rewriteFrontmatterListValue(raw, key, replacements) {
344
+ if (replacements.size === 0)
345
+ return null;
346
+ const lines = raw.split(/\r?\n/);
347
+ if (lines[0]?.trim() !== "---")
348
+ return null;
349
+ const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
350
+ if (closeIdx === -1)
351
+ return null;
352
+ const out = [];
353
+ let changed = false;
354
+ let index = 0;
355
+ while (index < lines.length) {
356
+ const line = lines[index];
357
+ if (index === 0 || index >= closeIdx) {
358
+ out.push(line);
359
+ index += 1;
360
+ continue;
361
+ }
362
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
363
+ if (kv === null || kv[1] !== key) {
364
+ out.push(line);
365
+ index += 1;
366
+ continue;
367
+ }
368
+ const rest = kv[2].trim();
369
+ // Inline flow: xrefs: [a, b]
370
+ const flow = rest.match(/^\[(.*)\]$/);
371
+ if (flow !== null) {
372
+ const items = flow[1].split(",").map((item) => item.trim());
373
+ const nextItems = items.map((item) => {
374
+ if (!item)
375
+ return item;
376
+ const replacement = replacements.get(unquote(item));
377
+ if (replacement === undefined)
378
+ return item;
379
+ changed = true;
380
+ return replacement;
381
+ });
382
+ out.push(items.length > 0 && items[0] !== "" ? `${kv[1]}: [${nextItems.join(", ")}]` : line);
383
+ index += 1;
384
+ continue;
385
+ }
386
+ // Bare scalar: xrefs: a
387
+ if (rest !== "") {
388
+ const replacement = replacements.get(unquote(rest));
389
+ if (replacement === undefined) {
390
+ out.push(line);
391
+ }
392
+ else {
393
+ changed = true;
394
+ out.push(`${kv[1]}: ${replacement}`);
395
+ }
396
+ index += 1;
397
+ continue;
398
+ }
399
+ // Block sequence: the key line, then ` - value` items.
400
+ out.push(line);
401
+ let cursor = index + 1;
402
+ while (cursor < closeIdx) {
403
+ const itemLine = lines[cursor];
404
+ const itemMatch = itemLine.match(/^(\s*-\s*)(.*)$/);
405
+ if (itemMatch === null)
406
+ break;
407
+ const [, prefix, value] = itemMatch;
408
+ const replacement = replacements.get(unquote(value));
409
+ if (replacement === undefined) {
410
+ out.push(itemLine);
411
+ }
412
+ else {
413
+ changed = true;
414
+ out.push(`${prefix}${replacement}`);
415
+ }
416
+ cursor += 1;
417
+ }
418
+ index = cursor;
419
+ }
420
+ return changed ? out.join("\n") : null;
421
+ }
326
422
  /**
327
423
  * Parse a YAML scalar value (string, boolean, or number).
328
424
  *
@@ -277,8 +277,14 @@ function isValidDirectory(dir) {
277
277
  try {
278
278
  return fs.statSync(dir).isDirectory();
279
279
  }
280
- catch {
281
- return false;
280
+ catch (error) {
281
+ // Genuinely absent — the caller's "not found" fallback applies. Any other
282
+ // stat failure (e.g. EACCES) is not "doesn't exist"; treating it as such
283
+ // produced the wrong message ("Run akm bundle create") for a directory
284
+ // that exists but cannot be read.
285
+ if (hasErrnoCode(error, "ENOENT"))
286
+ return false;
287
+ throw new ConfigError(`Unable to read bundle directory at "${dir}".`, "STASH_DIR_UNREADABLE");
282
288
  }
283
289
  }
284
290
  /**
@@ -356,6 +362,10 @@ function readStashDirFromConfig() {
356
362
  export function toPosix(input) {
357
363
  return input.replace(/\\/g, "/");
358
364
  }
365
+ /** Locale-independent code-point ordering — a stable `Array.prototype.sort` comparator for strings (paths, names, ids). */
366
+ export function compareCodePoints(left, right) {
367
+ return left < right ? -1 : left > right ? 1 : 0;
368
+ }
359
369
  export function hasErrnoCode(error, code) {
360
370
  if (typeof error !== "object" || error === null || !("code" in error))
361
371
  return false;
@@ -873,7 +883,61 @@ export function asNonEmptyString(value) {
873
883
  const trimmed = value.trim();
874
884
  return trimmed.length > 0 ? trimmed : undefined;
875
885
  }
886
+ // ── env-file assignment scanning ─────────────────────────────────────────────
887
+ /** Matches a `KEY=value` assignment line, capturing only the key. */
888
+ export const ENV_ASSIGN_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
889
+ /** Scan lines and return KEY names in file order, without duplicates. */
890
+ export function scanEnvKeyNames(text) {
891
+ const keys = [];
892
+ const seen = new Set();
893
+ for (const line of text.split(/\r?\n/)) {
894
+ const m = line.match(ENV_ASSIGN_LINE_RE);
895
+ if (!m)
896
+ continue;
897
+ const key = m[1];
898
+ if (!key)
899
+ continue;
900
+ if (seen.has(key))
901
+ continue;
902
+ seen.add(key);
903
+ keys.push(key);
904
+ }
905
+ return keys;
906
+ }
907
+ /** True when `value` contains no lone/unpaired UTF-16 surrogate (every surrogate is part of a valid pair). */
908
+ export function wellFormedUnicode(value) {
909
+ for (let index = 0; index < value.length; index += 1) {
910
+ const code = value.charCodeAt(index);
911
+ if (code >= 0xd800 && code <= 0xdbff) {
912
+ const next = value.charCodeAt(index + 1);
913
+ if (!(next >= 0xdc00 && next <= 0xdfff))
914
+ return false;
915
+ index += 1;
916
+ }
917
+ else if (code >= 0xdc00 && code <= 0xdfff)
918
+ return false;
919
+ }
920
+ return true;
921
+ }
876
922
  // ── Generic data utilities ───────────────────────────────────────────────────
923
+ /**
924
+ * Narrow an unknown value to a plain-ish record: an `object` that is neither
925
+ * `null` nor an `Array`. Does not distinguish a literal `{}` from a `Date`,
926
+ * `Map`, or class instance — callers that need that distinction use a
927
+ * stricter predicate instead.
928
+ */
929
+ export function isRecord(value) {
930
+ return typeof value === "object" && value !== null && !Array.isArray(value);
931
+ }
932
+ /** `JSON.parse` that returns `undefined` instead of throwing. */
933
+ export function tryParseJson(raw) {
934
+ try {
935
+ return JSON.parse(raw);
936
+ }
937
+ catch {
938
+ return undefined;
939
+ }
940
+ }
877
941
  /**
878
942
  * Coerce an unknown value to a filtered, trimmed string array.
879
943
  * Non-strings and empty/whitespace-only entries are dropped.
@@ -21,6 +21,7 @@
21
21
  * - z.record(...) → JSON-parse value, validate
22
22
  */
23
23
  import { z } from "zod";
24
+ import { isRecord } from "../common.js";
24
25
  import { UsageError } from "../errors.js";
25
26
  import { AkmConfigBaseSchema, EngineConfigSchema, listTopLevelConfigKeys } from "./config-schema.js";
26
27
  import { deepMergeConfig } from "./deep-merge.js";
@@ -110,7 +111,7 @@ function resolveSchemaAt(path, config, raw) {
110
111
  // Cannot descend into a non-object schema.
111
112
  return undefined;
112
113
  }
113
- existing = isPlainObject(existing) ? existing[segment] : undefined;
114
+ existing = isRecord(existing) ? existing[segment] : undefined;
114
115
  }
115
116
  // Preserve leaf refinements/transforms for validation. Traversal unwraps at
116
117
  // the start of each loop iteration, while coercion unwraps independently.
@@ -130,7 +131,7 @@ function selectUnionObjectOption(candidates, segment, existing, raw) {
130
131
  if (requested)
131
132
  return requested;
132
133
  }
133
- if (isPlainObject(existing)) {
134
+ if (isRecord(existing)) {
134
135
  const selected = options.find((option) => Object.entries(option.shape).some(([key, field]) => key in existing && unwrap(field) instanceof z.ZodLiteral && field.safeParse(existing[key]).success));
135
136
  if (selected)
136
137
  return segment in selected.shape ? selected : undefined;
@@ -197,9 +198,7 @@ export function configSet(config, dotted, raw) {
197
198
  // Merge a JSON object patch with the existing value before that current
198
199
  // schema transform,
199
200
  // otherwise the synthesized default can overwrite an explicit false.
200
- const candidate = judgmentObjectPath && isPlainObject(value)
201
- ? deepMergeConfig(isPlainObject(existing) ? existing : {}, value)
202
- : value;
201
+ const candidate = judgmentObjectPath && isRecord(value) ? deepMergeConfig(isRecord(existing) ? existing : {}, value) : value;
203
202
  // Validate the coerced value against the leaf schema. This catches enum
204
203
  // mismatches, out-of-range numbers, schema-level shape errors (writable
205
204
  // npm/website sources via .superRefine, strict-mode unknown keys in nested
@@ -220,7 +219,7 @@ export function configSet(config, dotted, raw) {
220
219
  .join("\n");
221
220
  throw new UsageError(`Invalid value for ${dotted}:\n${lines}`, "INVALID_FLAG_VALUE");
222
221
  }
223
- const next = setPath(config, path, isPlainObject(existing) && isPlainObject(parsed.data) ? deepMergeConfig(existing, parsed.data) : parsed.data);
222
+ const next = setPath(config, path, isRecord(existing) && isRecord(parsed.data) ? deepMergeConfig(existing, parsed.data) : parsed.data);
224
223
  // Targeted invariant: defaultWriteTarget must point at a configured bundle
225
224
  // (#464.a). Whole-config validation happens at save time; this check fires
226
225
  // at set time so the user sees the typo immediately.
@@ -277,7 +276,7 @@ function rejectApiKeyPath(path, dotted) {
277
276
  function parseObjectPatch(raw, key) {
278
277
  try {
279
278
  const value = JSON.parse(raw);
280
- if (!isPlainObject(value))
279
+ if (!isRecord(value))
281
280
  throw new Error("expected an object");
282
281
  return value;
283
282
  }
@@ -285,9 +284,6 @@ function parseObjectPatch(raw, key) {
285
284
  throw new UsageError(`Invalid JSON object for ${key}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_JSON_CONFIG_VALUE");
286
285
  }
287
286
  }
288
- function isPlainObject(value) {
289
- return value !== null && typeof value === "object" && !Array.isArray(value);
290
- }
291
287
  function recipeForApiKey(path, _dotted) {
292
288
  if (path[0] === "embedding")
293
289
  return "AKM_EMBED_API_KEY";
@@ -146,13 +146,16 @@ export function acquireConfigReadFence() {
146
146
  */
147
147
  export function parseAndValidateConfigText(text, sourcePath) {
148
148
  const parsedRaw = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
149
- // #852 (following #815): lift legacy `extraParams` keys — e.g.
150
- // `reasoning_effort`, a documented 0.9.1 workaround — onto the first-class
151
- // engine field they now shadow, before the protected-key check in
152
- // `ExtraParamsSchema` gets a chance to hard-reject them. In-memory only;
153
- // never written back to the file.
154
- const { config: raw, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
149
+ // #852 (following #815): a config still using legacy `extraParams` keys —
150
+ // e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be
151
+ // rewritten onto the first-class engine field they now shadow. This used
152
+ // to happen silently, in memory, on every load; that ran forever and never
153
+ // converged. The lift itself is now `akm migrate apply`'s job (see
154
+ // src/commands/migrate/config-extra-params.ts) and persists to disk, so a
155
+ // config that has not been migrated yet fails closed here instead of
156
+ // silently drifting from what's on disk.
155
157
  const where = sourcePath ? ` at ${sourcePath}` : "";
158
+ const { lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
156
159
  if (conflicts.length > 0) {
157
160
  const lines = conflicts
158
161
  .map((c) => ` - engines.${c.engine}.extraParams.${c.key} (${JSON.stringify(c.extraParamsValue)}) conflicts with engines.${c.engine}.${c.field} (${JSON.stringify(c.fieldValue)})`)
@@ -160,9 +163,9 @@ export function parseAndValidateConfigText(text, sourcePath) {
160
163
  throw new ConfigError(`Invalid config${where}: extraParams and the first-class field disagree:\n${lines}\n\nEach extraParams key above has a first-class equivalent and akm will not guess which value you meant — remove the extraParams entry once the field carries the value you want.`, "INVALID_CONFIG_FILE");
161
164
  }
162
165
  if (lifted.length > 0) {
163
- warn(`Config${where} uses deprecated extraParams keys with first-class equivalents; treating them as the first-class fields for this run (not written back to the file):\n - ${lifted.join("\n - ")}`);
166
+ throw new ConfigError(`Config${where} uses deprecated extraParams keys with first-class equivalents:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file, or move the values onto the first-class fields yourself.`, "INVALID_CONFIG_FILE");
164
167
  }
165
- const parsed = AkmConfigSchema.safeParse(raw);
168
+ const parsed = AkmConfigSchema.safeParse(parsedRaw);
166
169
  if (!parsed.success) {
167
170
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
168
171
  throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
@@ -1,6 +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 { isRecord } from "./common.js";
4
5
  export const EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS = [
5
6
  "model",
6
7
  "messages",
@@ -116,31 +117,34 @@ export function formatExtraParamsIssue(label, issue) {
116
117
  const suffix = issue.path.map((part) => (typeof part === "number" ? `[${part}]` : `.${part}`)).join("");
117
118
  return `${label}${suffix} ${issue.message}`;
118
119
  }
119
- function isPlainObject(value) {
120
- return typeof value === "object" && value !== null && !Array.isArray(value);
121
- }
122
120
  /**
123
- * Lift legacy `extraParams` keys onto their first-class engine field before
124
- * schema validation runs, so a 0.9.1-shaped config using (e.g.)
125
- * `extraParams.reasoning_effort` keeps loading now that `reasoningEffort` is
126
- * a first-class — and therefore protected — field (#852, following #815).
121
+ * Compute the legacy `extraParams` -> first-class-field lift for a raw
122
+ * parsed config object: a 0.9.1-shaped config using (e.g.)
123
+ * `extraParams.reasoning_effort` now needs `reasoningEffort` set instead,
124
+ * since that field became first-class — and therefore protected — in 0.9.2
125
+ * (#852, following #815).
127
126
  *
128
- * In-memory only: this never rewrites the config file. Callers should warn
129
- * using the returned `lifted` descriptions so the user knows to update the
130
- * file by hand, and reject using `conflicts` rather than silently preferring
131
- * either value.
127
+ * Pure: never touches the filesystem. Two callers use this differently:
128
+ * `akm migrate apply` (src/commands/migrate/config-extra-params.ts) uses the
129
+ * returned `config` to persist the rewrite to disk, once; `parseAndValidateConfigText`
130
+ * (src/core/config/config.ts) calls this only to detect whether a lift is
131
+ * needed and discards `config` — an unmigrated config fails closed there
132
+ * with a pointer to `akm migrate apply` rather than silently drifting from
133
+ * what's on disk. `conflicts` (an extraParams key and its first-class field
134
+ * set to different values) is a genuine authoring error in both callers and
135
+ * is never auto-resolved.
132
136
  */
133
137
  export function liftLegacyEngineExtraParams(raw) {
134
138
  const lifted = [];
135
139
  const conflicts = [];
136
140
  const rawEngines = raw.engines;
137
- if (!isPlainObject(rawEngines)) {
141
+ if (!isRecord(rawEngines)) {
138
142
  return { config: raw, lifted, conflicts };
139
143
  }
140
144
  const engines = {};
141
145
  let anyEngineChanged = false;
142
146
  for (const [name, engineValue] of Object.entries(rawEngines)) {
143
- if (!isPlainObject(engineValue) || !isPlainObject(engineValue.extraParams)) {
147
+ if (!isRecord(engineValue) || !isRecord(engineValue.extraParams)) {
144
148
  engines[name] = engineValue;
145
149
  continue;
146
150
  }
@@ -2,6 +2,7 @@
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 { cloneExecutionJsonObject } from "../execution/json.js";
5
+ import { isRecord } from "./common.js";
5
6
  const COMMON_FIELDS = [
6
7
  "schemaVersion",
7
8
  "ok",
@@ -50,9 +51,6 @@ const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]
50
51
  function fail(message) {
51
52
  throw new Error(`invalid improve-result envelope: ${message}`);
52
53
  }
53
- function isRecord(value) {
54
- return typeof value === "object" && value !== null && !Array.isArray(value);
55
- }
56
54
  function requireExactFields(value, allowed) {
57
55
  const unknown = Object.keys(value).filter((key) => !allowed.has(key));
58
56
  if (unknown.length > 0)
@@ -53,6 +53,7 @@
53
53
  * exactly that for `output:` and `params` schemas). It deliberately does NOT
54
54
  * change {@link validateJsonSchemaSubset}'s permissive evaluation semantics.
55
55
  */
56
+ import { isRecord } from "./common.js";
56
57
  /**
57
58
  * Deepest schema nesting either walk descends into — {@link
58
59
  * validateJsonSchemaSubset} evaluating a value, and {@link
@@ -168,9 +169,6 @@ function pointerFor(path) {
168
169
  function pushIssue(issues, path, keyword, kind, message) {
169
170
  issues.push({ path: [...path], pointer: pointerFor(path), keyword, kind, message });
170
171
  }
171
- function isPlainObject(value) {
172
- return typeof value === "object" && value !== null && !Array.isArray(value);
173
- }
174
172
  /** Values the runtime's reference-equality `enum` check can enforce correctly. */
175
173
  function isSupportedEnumValue(value) {
176
174
  return (value === null ||
@@ -228,7 +226,7 @@ function checkDefinitionNode(schema, path, issues, depth) {
228
226
  continue;
229
227
  }
230
228
  branches.forEach((branch, index) => {
231
- if (isPlainObject(branch)) {
229
+ if (isRecord(branch)) {
232
230
  checkDefinitionNode(branch, [...path, keyword, index], issues, depth + 1);
233
231
  }
234
232
  else {
@@ -237,7 +235,7 @@ function checkDefinitionNode(schema, path, issues, depth) {
237
235
  });
238
236
  }
239
237
  if (schema.not !== undefined) {
240
- if (isPlainObject(schema.not)) {
238
+ if (isRecord(schema.not)) {
241
239
  checkDefinitionNode(schema.not, [...path, "not"], issues, depth + 1);
242
240
  }
243
241
  else {
@@ -250,12 +248,12 @@ function checkDefinitionNode(schema, path, issues, depth) {
250
248
  }
251
249
  }
252
250
  if (schema.properties !== undefined) {
253
- if (!isPlainObject(schema.properties)) {
251
+ if (!isRecord(schema.properties)) {
254
252
  pushIssue(issues, [...path, "properties"], "properties", "malformed", `"properties" must be an object mapping property names to schemas`);
255
253
  }
256
254
  else {
257
255
  for (const [key, propSchema] of Object.entries(schema.properties)) {
258
- if (isPlainObject(propSchema)) {
256
+ if (isRecord(propSchema)) {
259
257
  checkDefinitionNode(propSchema, [...path, "properties", key], issues, depth + 1);
260
258
  }
261
259
  else {
@@ -265,7 +263,7 @@ function checkDefinitionNode(schema, path, issues, depth) {
265
263
  }
266
264
  }
267
265
  if (schema.items !== undefined) {
268
- if (isPlainObject(schema.items)) {
266
+ if (isRecord(schema.items)) {
269
267
  checkDefinitionNode(schema.items, [...path, "items"], issues, depth + 1);
270
268
  }
271
269
  else if (Array.isArray(schema.items)) {
@@ -276,7 +274,7 @@ function checkDefinitionNode(schema, path, issues, depth) {
276
274
  }
277
275
  }
278
276
  if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
279
- if (isPlainObject(schema.additionalProperties)) {
277
+ if (isRecord(schema.additionalProperties)) {
280
278
  pushIssue(issues, [...path, "additionalProperties"], "additionalProperties", "unsupported", `schema-form "additionalProperties" is not enforced by the workflow schema subset — only "additionalProperties: false" is`);
281
279
  }
282
280
  else {
@@ -329,7 +327,7 @@ function combinatorBranches(schema, keyword) {
329
327
  const raw = schema[keyword];
330
328
  if (!Array.isArray(raw))
331
329
  return [];
332
- return raw.filter(isPlainObject);
330
+ return raw.filter(isRecord);
333
331
  }
334
332
  /** First error of each failing branch, truncated — enough to act on without dumping every branch. */
335
333
  function summarizeBranchFailures(failures) {
@@ -364,7 +362,7 @@ function validateCombinators(value, schema, path, ctx) {
364
362
  }
365
363
  }
366
364
  const not = schema.not;
367
- if (isPlainObject(not) && branchErrors(value, not, path, ctx).length === 0) {
365
+ if (isRecord(not) && branchErrors(value, not, path, ctx).length === 0) {
368
366
  ctx.errors.push(`${path}: value must not match the "not" schema`);
369
367
  }
370
368
  }
@@ -35,6 +35,7 @@ export const STATE_MIGRATION_SAFETY_BY_ID = Object.freeze({
35
35
  "022-workflow-unit-attempts": "additive",
36
36
  "023-child-workflow-runs": "additive",
37
37
  "024-workflow-run-outputs": "additive",
38
+ "025-task-history-vocabulary-backfill": "data-preserving-rebuild",
38
39
  });
39
40
  export const STATE_MIGRATIONS = [
40
41
  // ── Migration 001 — initial schema ──────────────────────────────────────────
@@ -1079,6 +1080,53 @@ export const STATE_MIGRATIONS = [
1079
1080
  ALTER TABLE workflow_runs ADD COLUMN outputs_json TEXT;
1080
1081
  `,
1081
1082
  },
1083
+ // ── Migration 025 — task_history D8 result-vocabulary backfill ─────────────
1084
+ //
1085
+ // The D8 re-code (docs/architecture/decisions/0005-task-result-vocabulary-
1086
+ // and-legacy-read-mapping.md) renamed the written `target_kind` vocabulary
1087
+ // and marked every new row with `targetVocab: 2` in `metadata_json`, but
1088
+ // left every row written before it in the OLD vocabulary forever, pushing
1089
+ // the "which generation is this row" decision onto three separate read
1090
+ // sites (src/tasks/run/task-history.ts, src/commands/health/
1091
+ // improve-metrics.ts, src/commands/health/windows.ts). That is exactly the
1092
+ // shape `docs/plans/specs/p4-deletions-closeout.md` row B-51 ratified
1093
+ // keeping "forever" — SUPERSEDED here: `task_history` is DB-owned data, the
1094
+ // remap is deterministic and total, so it belongs in a one-time migration,
1095
+ // not three permanently-recurring read-side shims.
1096
+ //
1097
+ // The remap, applied only to rows with no `targetVocab` marker yet (a
1098
+ // NULL/absent `$.targetVocab` in the JSON column — already-marked or
1099
+ // malformed metadata_json is left untouched; malformed rows are a
1100
+ // pre-existing, unrelated skip-and-warn concern at read time):
1101
+ // - target_kind 'prompt' -> 'command' (the prepared command/agent-LLM
1102
+ // arm; D8 renamed its written label, this backfills the old one)
1103
+ // - target_kind 'command' -> 'shell' (the legacy native shell/script
1104
+ // arm's shared old label; D8 split it into 'shell'/'script', and only
1105
+ // 'shell' is reconstructable — the old vocabulary never distinguished
1106
+ // script from shell, matching the read mapping this migration retires)
1107
+ // Only 'prompt' and legacy 'command' rows actually change meaning between
1108
+ // vocabularies (see the read mapping this migration retires); every other
1109
+ // target_kind ('workflow', NULL, or anything else) already reads
1110
+ // identically in both, so it is left alone — including its metadata_json,
1111
+ // since no reader consults `targetVocab` once the shim is deleted.
1112
+ {
1113
+ id: "025-task-history-vocabulary-backfill",
1114
+ up: `
1115
+ UPDATE task_history
1116
+ SET target_kind = 'command',
1117
+ metadata_json = json_set(metadata_json, '$.targetVocab', 2)
1118
+ WHERE target_kind = 'prompt'
1119
+ AND json_valid(metadata_json)
1120
+ AND json_extract(metadata_json, '$.targetVocab') IS NULL;
1121
+
1122
+ UPDATE task_history
1123
+ SET target_kind = 'shell',
1124
+ metadata_json = json_set(metadata_json, '$.targetVocab', 2)
1125
+ WHERE target_kind = 'command'
1126
+ AND json_valid(metadata_json)
1127
+ AND json_extract(metadata_json, '$.targetVocab') IS NULL;
1128
+ `,
1129
+ },
1082
1130
  ];
1083
1131
  assertMigrationRegistry(STATE_MIGRATIONS);
1084
1132
  function assertStateMigrationSafetyRegistry() {
@@ -1117,7 +1165,8 @@ export function runMigrations(db, options) {
1117
1165
  }
1118
1166
  if (!options.allowHistoricalDestructiveStateUpgrade) {
1119
1167
  throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
1120
- "Run `akm upgrade --force` to snapshot it before migration 001.");
1168
+ "Run `akm upgrade --force` to snapshot it before migration 001, " +
1169
+ "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1121
1170
  }
1122
1171
  const ledger = assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1123
1172
  if (ledger.migrationIds.length !== 0) {
@@ -1147,7 +1196,8 @@ export function runMigrations(db, options) {
1147
1196
  assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1148
1197
  if (!options?.allowHistoricalDestructiveStateUpgrade) {
1149
1198
  throw new Error(`Refusing to apply historical destructive state migration ${migration.id} during an ordinary managed open. ` +
1150
- "Run `akm upgrade --force` to create a sibling state.db safety copy and apply it deliberately.");
1199
+ "Run `akm upgrade --force` to create a sibling state.db safety copy and apply it deliberately, " +
1200
+ "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1151
1201
  }
1152
1202
  if (!options.beforeHistoricalDestructiveMigration) {
1153
1203
  throw new Error(`Historical destructive state migration ${migration.id} requires a verified safety-copy hook.`);
@@ -407,7 +407,8 @@ export function openStateDatabase(dbPath, options) {
407
407
  existingUnversionedDatabase = ledger.migrationIds.length === 0;
408
408
  if (existingUnversionedDatabase && !options?.allowHistoricalDestructiveStateUpgrade) {
409
409
  throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
410
- "Run `akm upgrade --force` to create a verified snapshot before migration 001.");
410
+ "Run `akm upgrade --force` to create a verified snapshot before migration 001, " +
411
+ "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
411
412
  }
412
413
  }
413
414
  finally {
@@ -4,6 +4,7 @@
4
4
  import { createHash } from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { isRecord } from "../core/common.js";
7
8
  import { UsageError } from "../core/errors.js";
8
9
  export function freezeExecutableIdentity(requested, options = {}) {
9
10
  const absolutePath = resolveExecutable(requested, options.cwd ?? process.cwd(), options.path ?? process.env.PATH ?? "");
@@ -96,9 +97,6 @@ function resolveExecutable(requested, cwd, pathValue) {
96
97
  }
97
98
  throw new UsageError(`Executable ${JSON.stringify(requested)} was not found.`, "INVALID_FLAG_VALUE");
98
99
  }
99
- function isRecord(value) {
100
- return typeof value === "object" && value !== null && !Array.isArray(value);
101
- }
102
100
  function invalid(label) {
103
101
  return new UsageError(`Frozen ${label} identity is invalid.`, "INVALID_FLAG_VALUE");
104
102
  }