akm-cli 0.9.7 → 0.9.8-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/data-dir-usage.js +165 -0
  5. package/dist/commands/health/improve-metrics.js +8 -34
  6. package/dist/commands/health/windows.js +0 -4
  7. package/dist/commands/health.js +18 -37
  8. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  9. package/dist/commands/improve/extract.js +36 -32
  10. package/dist/commands/improve/memory/memory-belief.js +15 -5
  11. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  12. package/dist/commands/improve/memory/memory-improve.js +7 -19
  13. package/dist/commands/improve/preparation.js +52 -36
  14. package/dist/commands/improve/reflect.js +13 -17
  15. package/dist/commands/lint/base-linter.js +42 -2
  16. package/dist/commands/lint/index.js +4 -5
  17. package/dist/commands/migrate/config-extra-params.js +61 -0
  18. package/dist/commands/migrate/dead-residue.js +113 -0
  19. package/dist/commands/migrate/stale-txn.js +49 -0
  20. package/dist/commands/migrate-cli.js +42 -1
  21. package/dist/commands/proposal/proposal.js +1 -21
  22. package/dist/commands/proposal/repository.js +0 -4
  23. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  24. package/dist/commands/read/curate.js +4 -18
  25. package/dist/commands/read/show.js +2 -1
  26. package/dist/commands/sources/installed-stashes.js +5 -1
  27. package/dist/commands/sources/self-update.js +38 -1
  28. package/dist/commands/sources/sources-cli.js +17 -1
  29. package/dist/commands/tasks/tasks.js +0 -8
  30. package/dist/commands/url-checker.js +20 -28
  31. package/dist/commands/workflow-cli.js +11 -61
  32. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  33. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  34. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  35. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  36. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  37. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  38. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  39. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  40. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  41. package/dist/core/adapter/adapters/shared.js +2 -0
  42. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  43. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  44. package/dist/core/adapter/validate-context.js +1 -3
  45. package/dist/core/asset/asset-placement.js +1 -3
  46. package/dist/core/asset/frontmatter.js +96 -0
  47. package/dist/core/common.js +66 -2
  48. package/dist/core/config/config-io.js +21 -8
  49. package/dist/core/config/config-walker.js +6 -10
  50. package/dist/core/config/config.js +11 -8
  51. package/dist/core/extra-params.js +17 -13
  52. package/dist/core/improve-result.js +1 -3
  53. package/dist/core/json-schema.js +9 -11
  54. package/dist/core/state/migrations.js +74 -2
  55. package/dist/core/state-db.js +2 -1
  56. package/dist/core/warn.js +15 -0
  57. package/dist/execution/executable-identity.js +1 -3
  58. package/dist/execution/guarded-source.js +1 -6
  59. package/dist/indexer/bundle-identity-guard.js +6 -1
  60. package/dist/indexer/db/graph-db.js +139 -154
  61. package/dist/indexer/ensure-index.js +11 -19
  62. package/dist/indexer/graph/graph-boost.js +23 -34
  63. package/dist/indexer/graph/graph-extraction.js +12 -2
  64. package/dist/indexer/indexer.js +43 -22
  65. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  66. package/dist/indexer/passes/dir-staleness.js +30 -9
  67. package/dist/indexer/passes/memory-inference.js +7 -2
  68. package/dist/indexer/scan/drain-dir.js +2 -4
  69. package/dist/indexer/search/db-search.js +3 -3
  70. package/dist/indexer/search/fts-query.js +10 -15
  71. package/dist/indexer/search/search-source.js +0 -13
  72. package/dist/indexer/usage/usage-events.js +9 -1
  73. package/dist/indexer/walk/walker.js +11 -6
  74. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  75. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  76. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  77. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  78. package/dist/integrations/harnesses/index.js +0 -4
  79. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  80. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  81. package/dist/integrations/lockfile.js +0 -14
  82. package/dist/integrations/session-logs/index.js +0 -81
  83. package/dist/llm/client.js +0 -12
  84. package/dist/llm/memory-infer.js +1 -3
  85. package/dist/llm/usage-telemetry.js +1 -3
  86. package/dist/registry/create-provider-registry.js +4 -0
  87. package/dist/registry/factory.js +4 -0
  88. package/dist/registry/resolve.js +23 -8
  89. package/dist/runtime.js +0 -12
  90. package/dist/scripts/akm-migrate-node.js +179 -248
  91. package/dist/scripts/akm-migrate.js +179 -248
  92. package/dist/setup/setup.js +6 -12
  93. package/dist/sources/providers/git-install.js +7 -2
  94. package/dist/sources/providers/tar-utils.js +1 -7
  95. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  96. package/dist/storage/managed-db.js +20 -7
  97. package/dist/storage/repositories/events-repository.js +0 -81
  98. package/dist/storage/repositories/index-entries-repository.js +24 -28
  99. package/dist/storage/repositories/index-fts-repository.js +24 -30
  100. package/dist/storage/repositories/index-meta-repository.js +6 -4
  101. package/dist/storage/repositories/index-schema.js +16 -1
  102. package/dist/storage/repositories/index-utility-repository.js +0 -57
  103. package/dist/storage/repositories/index-vec-repository.js +25 -27
  104. package/dist/storage/repositories/proposals-repository.js +4 -1
  105. package/dist/storage/repositories/task-history-repository.js +9 -3
  106. package/dist/tasks/backends/launchd.js +8 -18
  107. package/dist/tasks/resolve-akm-bin.js +15 -0
  108. package/dist/tasks/run/task-history.js +21 -31
  109. package/dist/tasks/scheduler-sync.js +1 -6
  110. package/dist/tasks/source/bounded-document.js +1 -14
  111. package/dist/tasks/source/task-to-v3.js +10 -2
  112. package/dist/workflows/exec/child-workflow.js +1 -1
  113. package/dist/workflows/exec/native-executor.js +2 -2
  114. package/dist/workflows/exec/step-work.js +5 -17
  115. package/dist/workflows/exec/worktree.js +40 -6
  116. package/dist/workflows/freeze/task-bindings.js +2 -4
  117. package/dist/workflows/ir/compile.js +3 -14
  118. package/dist/workflows/ir/schema-v4.js +4 -6
  119. package/dist/workflows/ir/schema.js +2 -5
  120. package/dist/workflows/parser.js +23 -26
  121. package/dist/workflows/source-files.js +8 -13
  122. package/dist/workflows/source-ir/schema.js +1 -19
  123. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
  125. package/docs/reference/cli.md +3 -1
  126. package/docs/reference/data-and-telemetry.md +1 -0
  127. package/docs/reference/tasks.md +11 -0
  128. package/docs/reference/workflow-schema.md +24 -0
  129. package/package.json +2 -9
  130. package/dist/workflows/source-ir/compare.js +0 -17
@@ -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.
@@ -115,17 +115,30 @@ export function backupExistingConfig(configPath, now = new Date()) {
115
115
  return { timestamped, latest };
116
116
  }
117
117
  function pruneOldBackups(backupDir) {
118
+ pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
119
+ }
120
+ function isTimestampedConfigBackup(name) {
121
+ return name.startsWith("config-") && name.endsWith(".json") && name !== "config.latest.json";
122
+ }
123
+ /**
124
+ * Keep the `keep` most-recently-modified entries of `dir` that `select`
125
+ * admits and remove the rest. Best-effort: an unreadable dir is a no-op, an
126
+ * unreadable entry sorts oldest (pruned first), and a failed removal is
127
+ * retried by the next call. Shared by the config backups above and the task
128
+ * migration snapshots (#897).
129
+ */
130
+ export function pruneToNewest(dir, keep, select) {
118
131
  let entries;
119
132
  try {
120
- entries = fs.readdirSync(backupDir);
133
+ entries = fs.readdirSync(dir, { withFileTypes: true });
121
134
  }
122
135
  catch {
123
136
  return;
124
137
  }
125
- const timestamped = entries
126
- .filter((n) => n.startsWith("config-") && n.endsWith(".json") && n !== "config.latest.json")
127
- .map((name) => {
128
- const full = path.join(backupDir, name);
138
+ const candidates = entries
139
+ .filter(select)
140
+ .map((entry) => {
141
+ const full = path.join(dir, entry.name);
129
142
  let mtime = 0;
130
143
  try {
131
144
  mtime = fs.statSync(full).mtimeMs;
@@ -136,12 +149,12 @@ function pruneOldBackups(backupDir) {
136
149
  return { path: full, mtime };
137
150
  })
138
151
  .sort((a, b) => b.mtime - a.mtime);
139
- for (const stale of timestamped.slice(MAX_CONFIG_BACKUPS)) {
152
+ for (const stale of candidates.slice(keep)) {
140
153
  try {
141
- fs.unlinkSync(stale.path);
154
+ fs.rmSync(stale.path, { recursive: true, force: true });
142
155
  }
143
156
  catch {
144
- // Best-effort prune; next save will retry.
157
+ // Best-effort prune; the next call will retry.
145
158
  }
146
159
  }
147
160
  }
@@ -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,8 @@ 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",
39
+ "026-proposals-strip-legacy-fragment-refs": "data-preserving-rebuild",
38
40
  });
39
41
  export const STATE_MIGRATIONS = [
40
42
  // ── Migration 001 — initial schema ──────────────────────────────────────────
@@ -1079,6 +1081,74 @@ export const STATE_MIGRATIONS = [
1079
1081
  ALTER TABLE workflow_runs ADD COLUMN outputs_json TEXT;
1080
1082
  `,
1081
1083
  },
1084
+ // ── Migration 025 — task_history D8 result-vocabulary backfill ─────────────
1085
+ //
1086
+ // The D8 re-code (docs/architecture/decisions/0005-task-result-vocabulary-
1087
+ // and-legacy-read-mapping.md) renamed the written `target_kind` vocabulary
1088
+ // and marked every new row with `targetVocab: 2` in `metadata_json`, but
1089
+ // left every row written before it in the OLD vocabulary forever, pushing
1090
+ // the "which generation is this row" decision onto three separate read
1091
+ // sites (src/tasks/run/task-history.ts, src/commands/health/
1092
+ // improve-metrics.ts, src/commands/health/windows.ts). That is exactly the
1093
+ // shape `docs/plans/specs/p4-deletions-closeout.md` row B-51 ratified
1094
+ // keeping "forever" — SUPERSEDED here: `task_history` is DB-owned data, the
1095
+ // remap is deterministic and total, so it belongs in a one-time migration,
1096
+ // not three permanently-recurring read-side shims.
1097
+ //
1098
+ // The remap, applied only to rows with no `targetVocab` marker yet (a
1099
+ // NULL/absent `$.targetVocab` in the JSON column — already-marked or
1100
+ // malformed metadata_json is left untouched; malformed rows are a
1101
+ // pre-existing, unrelated skip-and-warn concern at read time):
1102
+ // - target_kind 'prompt' -> 'command' (the prepared command/agent-LLM
1103
+ // arm; D8 renamed its written label, this backfills the old one)
1104
+ // - target_kind 'command' -> 'shell' (the legacy native shell/script
1105
+ // arm's shared old label; D8 split it into 'shell'/'script', and only
1106
+ // 'shell' is reconstructable — the old vocabulary never distinguished
1107
+ // script from shell, matching the read mapping this migration retires)
1108
+ // Only 'prompt' and legacy 'command' rows actually change meaning between
1109
+ // vocabularies (see the read mapping this migration retires); every other
1110
+ // target_kind ('workflow', NULL, or anything else) already reads
1111
+ // identically in both, so it is left alone — including its metadata_json,
1112
+ // since no reader consults `targetVocab` once the shim is deleted.
1113
+ {
1114
+ id: "025-task-history-vocabulary-backfill",
1115
+ up: `
1116
+ UPDATE task_history
1117
+ SET target_kind = 'command',
1118
+ metadata_json = json_set(metadata_json, '$.targetVocab', 2)
1119
+ WHERE target_kind = 'prompt'
1120
+ AND json_valid(metadata_json)
1121
+ AND json_extract(metadata_json, '$.targetVocab') IS NULL;
1122
+
1123
+ UPDATE task_history
1124
+ SET target_kind = 'shell',
1125
+ metadata_json = json_set(metadata_json, '$.targetVocab', 2)
1126
+ WHERE target_kind = 'command'
1127
+ AND json_valid(metadata_json)
1128
+ AND json_extract(metadata_json, '$.targetVocab') IS NULL;
1129
+ `,
1130
+ },
1131
+ // ── Migration 026 — strip the retired proposal-ref export fragment (#898) ──
1132
+ //
1133
+ // Older releases could write a proposal `ref` carrying the export-fragment
1134
+ // selector (`[bundle//]conceptId#fragment`, see src/core/asset/asset-ref.ts).
1135
+ // `currentProposalRef` (proposals-repository.ts) now rejects any fragment, so
1136
+ // those rows failed to parse on every read and could not be listed, repaired,
1137
+ // or deleted through the CLI. Every surface that still reads an archived row
1138
+ // is concept-scoped, so the fragment carries nothing they use.
1139
+ //
1140
+ // `#` is legal in a ref only as the fragment separator, so truncating at the
1141
+ // first `#` is exactly the split `parseBundleRef` performs. `ref` has no
1142
+ // UNIQUE constraint, and no writer ever produced an empty concept id, so
1143
+ // every legacy row normalizes without a drop path.
1144
+ {
1145
+ id: "026-proposals-strip-legacy-fragment-refs",
1146
+ up: `
1147
+ UPDATE proposals
1148
+ SET ref = substr(ref, 1, instr(ref, '#') - 1)
1149
+ WHERE ref LIKE '%#%';
1150
+ `,
1151
+ },
1082
1152
  ];
1083
1153
  assertMigrationRegistry(STATE_MIGRATIONS);
1084
1154
  function assertStateMigrationSafetyRegistry() {
@@ -1117,7 +1187,8 @@ export function runMigrations(db, options) {
1117
1187
  }
1118
1188
  if (!options.allowHistoricalDestructiveStateUpgrade) {
1119
1189
  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.");
1190
+ "Run `akm upgrade --force` to snapshot it before migration 001, " +
1191
+ "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1121
1192
  }
1122
1193
  const ledger = assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1123
1194
  if (ledger.migrationIds.length !== 0) {
@@ -1147,7 +1218,8 @@ export function runMigrations(db, options) {
1147
1218
  assertMigrationLedger(lockedDb, STATE_MIGRATIONS);
1148
1219
  if (!options?.allowHistoricalDestructiveStateUpgrade) {
1149
1220
  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.");
1221
+ "Run `akm upgrade --force` to create a sibling state.db safety copy and apply it deliberately, " +
1222
+ "or `akm upgrade --state-only` where akm cannot reinstall itself (container/global install).");
1151
1223
  }
1152
1224
  if (!options.beforeHistoricalDestructiveMigration) {
1153
1225
  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 {
package/dist/core/warn.js CHANGED
@@ -122,6 +122,21 @@ export function warn(...args) {
122
122
  console.warn(...args);
123
123
  }
124
124
  }
125
+ const warnedOnceKeys = new Set();
126
+ /**
127
+ * `warn()` at most once per process for a given `key`. For diagnostics that
128
+ * would otherwise repeat on every read of the same bad row or value.
129
+ */
130
+ export function warnOnce(key, ...args) {
131
+ if (warnedOnceKeys.has(key))
132
+ return;
133
+ warnedOnceKeys.add(key);
134
+ warn(...args);
135
+ }
136
+ /** TEST-ONLY. Forget every `warnOnce` key so a test can re-trigger a warning. */
137
+ export function _resetWarnOnceForTests() {
138
+ warnedOnceKeys.clear();
139
+ }
125
140
  /**
126
141
  * Emit an error to stderr unless --quiet is active.
127
142
  * Always written to the log file if one is active.
@@ -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
  }
@@ -5,17 +5,12 @@ import { createHash } from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { parseFrontmatter } from "../core/asset/frontmatter.js";
8
+ import { compareCodePoints, toPosix } from "../core/common.js";
8
9
  import { UsageError } from "../core/errors.js";
9
10
  import { createExecutionSourceIdentity } from "./source.js";
10
11
  function errorMessage(cause) {
11
12
  return cause instanceof Error ? cause.message : String(cause);
12
13
  }
13
- function toPosix(value) {
14
- return value.replaceAll("\\", "/");
15
- }
16
- function compareCodePoints(left, right) {
17
- return left < right ? -1 : left > right ? 1 : 0;
18
- }
19
14
  function physicalIdentity(realPath, stat) {
20
15
  return stat.ino === 0n ? `path:${realPath}` : `inode:${stat.dev}:${stat.ino}`;
21
16
  }
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import fs from "node:fs";
31
31
  import { loadConfig } from "../core/config/config.js";
32
+ import { rethrowIfTestIsolationError } from "../core/errors.js";
32
33
  import { getDbPath } from "../core/paths.js";
33
34
  import { warn } from "../core/warn.js";
34
35
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
@@ -50,7 +51,11 @@ function indexBundlePrefixes(dbPath) {
50
51
  .prepare("SELECT DISTINCT bundle_id AS b FROM entries WHERE bundle_id IS NOT NULL AND bundle_id != ''")
51
52
  .all().map((row) => row.b);
52
53
  }
53
- catch {
54
+ catch (error) {
55
+ rethrowIfTestIsolationError(error);
56
+ // Best-effort startup diagnostic: a locked/corrupted index just means this
57
+ // pass skips the drift check (retried on the next command) rather than
58
+ // blocking every command on a heuristic warning.
54
59
  return undefined;
55
60
  }
56
61
  finally {