akm-cli 0.9.3 → 0.9.5

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 (56) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/anti-collapse.js +4 -91
  12. package/dist/commands/improve/preparation.js +8 -1
  13. package/dist/commands/lint/index.js +3 -7
  14. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  15. package/dist/commands/read/search.js +14 -24
  16. package/dist/commands/tasks/tasks-cli.js +81 -3
  17. package/dist/commands/tasks/tasks.js +117 -2
  18. package/dist/core/adapter/adapters/akm-adapter.js +23 -14
  19. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  20. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  21. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  22. package/dist/core/adapter/recognize-match.js +1 -20
  23. package/dist/core/asset/asset-placement.js +21 -2
  24. package/dist/core/common.js +21 -1
  25. package/dist/core/config/config-version-shim.js +101 -0
  26. package/dist/core/config/config.js +6 -6
  27. package/dist/core/improve-result.js +35 -14
  28. package/dist/execution/guarded-source.js +0 -10
  29. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  30. package/dist/indexer/passes/metadata.js +12 -4
  31. package/dist/indexer/scan/doc-to-entry.js +2 -0
  32. package/dist/indexer/search/db-search.js +6 -0
  33. package/dist/indexer/search/search-fields.js +16 -1
  34. package/dist/indexer/walk/matchers.js +0 -22
  35. package/dist/output/shapes/helpers.js +19 -1
  36. package/dist/output/shapes/passthrough.js +18 -5
  37. package/dist/output/text/command-format.js +4 -0
  38. package/dist/registry/pinned-request-helper.js +2 -2
  39. package/dist/registry/pinned-transport.js +6 -6
  40. package/dist/scripts/akm-migrate-node.js +12678 -12601
  41. package/dist/scripts/akm-migrate.js +12678 -12601
  42. package/dist/storage/repositories/proposals-repository.js +65 -7
  43. package/dist/storage/repositories/task-history-repository.js +22 -10
  44. package/dist/tasks/run/task-history.js +23 -3
  45. package/dist/tasks/scheduler-binding.js +15 -5
  46. package/dist/tasks/scheduler-sync-preview.js +45 -0
  47. package/dist/tasks/scheduler-sync.js +77 -41
  48. package/dist/tasks/source/bounded-document.js +1 -1
  49. package/dist/tasks/source/parse-task-source.js +77 -11
  50. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  51. package/dist/tasks/source/task-to-v3.js +512 -0
  52. package/dist/tasks/source/task-to-v4.js +457 -0
  53. package/docs/reference/cli.md +33 -6
  54. package/docs/reference/configuration.md +27 -6
  55. package/docs/reference/tasks.md +10 -0
  56. package/package.json +4 -4
@@ -0,0 +1,101 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The `configVersion` read shim (#863).
6
+ *
7
+ * `configVersion` used to be a hard `z.literal(CURRENT_CONFIG_VERSION)` gate:
8
+ * every value other than the exact current string threw
9
+ * `UNSUPPORTED_CONFIG_VERSION` for every command, since every akm invocation
10
+ * loads config first. That is the same shape of break #858/#859 (proposal
11
+ * rows), the `task_history` `metadataVersion` gate, and the task-source v2/v3
12
+ * gate each caused in 0.9.x — a version gate with no read shim — except
13
+ * `configVersion`'s blast radius is the whole CLI rather than one subsystem.
14
+ *
15
+ * This module mirrors the in-tree template for that fix,
16
+ * `src/tasks/source/parse-task-source.ts`'s v2/v3 -> v4 shim: a known-old
17
+ * version routes through a pure, in-memory upgrade function to the current
18
+ * shape, with a one-line stderr deprecation warning; the result is never
19
+ * written back to disk (the on-disk rewrite already happens for free — every
20
+ * `saveConfig`/`mutateConfig` write forces `configVersion` to
21
+ * {@link CURRENT_CONFIG_VERSION}, so the very next `akm config set` or any
22
+ * other mutating command silences the warning permanently). A version that is
23
+ * neither current nor a known old version — including anything NEWER than
24
+ * current — still fails closed with `UNSUPPORTED_CONFIG_VERSION`:
25
+ * forward-incompatibility is a real hazard (an older binary must not guess at
26
+ * a newer, unknown shape) and this shim does not soften that.
27
+ *
28
+ * IMPORTANT — as of this writing, `"0.9.0"` is the only `configVersion` akm
29
+ * has ever shipped; there is no real prior release to shim. `"0.0.1"` below
30
+ * is a SYNTHETIC placeholder entry that exists solely to stand up and
31
+ * exercise this mechanism — the known-versions list, the dispatch table, the
32
+ * warn-once-and-upgrade behavior, the fail-closed behavior for anything
33
+ * else — before a real bump ever needs it (see
34
+ * `tests/integration/config-version-shim.test.ts` and the
35
+ * `previous-release-corpus.test.ts` fixture). When the first genuine
36
+ * `configVersion` bump ships, add its real old shape as its own entry the
37
+ * same way and delete the synthetic `"0.0.1"` entry (and this paragraph) in
38
+ * the same change.
39
+ */
40
+ import { ConfigError } from "../errors.js";
41
+ import { warn } from "../warn.js";
42
+ import { CURRENT_CONFIG_VERSION } from "./schema/primitives.js";
43
+ /**
44
+ * Every `configVersion` this binary can still READ, other than
45
+ * {@link CURRENT_CONFIG_VERSION} itself — each with an in-memory upgrade
46
+ * function in {@link CONFIG_VERSION_UPGRADES}. Anything not in this list (and
47
+ * not equal to current) fails closed.
48
+ */
49
+ export const KNOWN_OLD_CONFIG_VERSIONS = ["0.0.1"];
50
+ function isKnownOldConfigVersion(value) {
51
+ return typeof value === "string" && KNOWN_OLD_CONFIG_VERSIONS.includes(value);
52
+ }
53
+ /**
54
+ * SYNTHETIC 0.0.1 -> 0.9.0 upgrade (placeholder — see module doc). Per this
55
+ * placeholder, 0.0.1 kept the default LLM engine name at the config root as
56
+ * `defaultEngine`; 0.9.0 moved it under `defaults.llmEngine`. Pure function:
57
+ * takes the raw parsed JSON object, returns a new raw object with the 0.9.0
58
+ * shape. Never touches disk.
59
+ */
60
+ function upgradeFrom080(raw) {
61
+ const { defaultEngine, defaults, ...rest } = raw;
62
+ if (typeof defaultEngine !== "string" || defaultEngine.length === 0) {
63
+ return { ...rest, ...(defaults !== undefined ? { defaults } : {}), configVersion: CURRENT_CONFIG_VERSION };
64
+ }
65
+ const existingDefaults = defaults !== null && typeof defaults === "object" ? defaults : {};
66
+ return {
67
+ ...rest,
68
+ configVersion: CURRENT_CONFIG_VERSION,
69
+ // An explicit `defaults.llmEngine` already present in the raw 0.0.1
70
+ // document (should never happen for a real 0.0.1 file, but a malformed
71
+ // one is possible) wins over the root-level field being migrated in.
72
+ defaults: { llmEngine: defaultEngine, ...existingDefaults },
73
+ };
74
+ }
75
+ const CONFIG_VERSION_UPGRADES = {
76
+ "0.0.1": upgradeFrom080,
77
+ };
78
+ function unsupportedConfigVersionError(rawVersion, sourcePath) {
79
+ const where = sourcePath ? ` at ${sourcePath}` : "";
80
+ const supported = [CURRENT_CONFIG_VERSION, ...KNOWN_OLD_CONFIG_VERSIONS].map((v) => `"${v}"`).join(", ");
81
+ return new ConfigError(`Unsupported configVersion${where}: got ${JSON.stringify(rawVersion)}, expected one of ${supported}.`, "UNSUPPORTED_CONFIG_VERSION", "Recreate engines and improve.strategies manually for AKM 0.9.0; profile-based configuration is not translated automatically.");
82
+ }
83
+ /**
84
+ * Route a raw parsed config object through the version shim before schema
85
+ * validation. Returns a raw object whose `configVersion` is
86
+ * {@link CURRENT_CONFIG_VERSION} — either unchanged (already current),
87
+ * in-memory-upgraded (a known old version, with a one-line stderr warning),
88
+ * or this throws `UNSUPPORTED_CONFIG_VERSION` (unknown, newer, missing, or
89
+ * malformed).
90
+ */
91
+ export function upgradeConfigVersion(raw, sourcePath) {
92
+ const version = raw.configVersion;
93
+ if (version === CURRENT_CONFIG_VERSION)
94
+ return raw;
95
+ if (isKnownOldConfigVersion(version)) {
96
+ const upgraded = CONFIG_VERSION_UPGRADES[version](raw);
97
+ warn(`Config${sourcePath ? ` at ${sourcePath}` : ""} uses configVersion "${version}" — auto-upgraded to ${CURRENT_CONFIG_VERSION} in memory; the next config write (e.g. \`akm config set\`) persists this and silences the warning.`);
98
+ return upgraded;
99
+ }
100
+ throw unsupportedConfigVersionError(version, sourcePath);
101
+ }
@@ -8,6 +8,7 @@ import { liftLegacyEngineExtraParams } from "../extra-params.js";
8
8
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
9
9
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
10
10
  import { bundlesToSourceEntries } from "./config-sources.js";
11
+ import { upgradeConfigVersion } from "./config-version-shim.js";
11
12
  import { deepMergeConfig } from "./deep-merge.js";
12
13
  export { stripJsonComments } from "./config-io.js";
13
14
  import { getConfigPath } from "../paths.js";
@@ -138,14 +139,13 @@ export function acquireConfigReadFence() {
138
139
  * Parse raw config text and validate via Zod.
139
140
  * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
140
141
  *
141
- * The schema accepts only the current config version and validates the
142
- * canonical shape before defaults are merged.
142
+ * The schema accepts only the current config version. A known older version
143
+ * is auto-upgraded in memory first (see `./config-version-shim`); anything
144
+ * else — including anything newer — is rejected before the canonical shape
145
+ * is validated.
143
146
  */
144
147
  export function parseAndValidateConfigText(text, sourcePath) {
145
- const parsedRaw = parseConfigText(text, sourcePath);
146
- if (parsedRaw.configVersion !== CURRENT_CONFIG_VERSION) {
147
- throw new ConfigError(`Unsupported configVersion${sourcePath ? ` at ${sourcePath}` : ""}: expected "${CURRENT_CONFIG_VERSION}".`, "UNSUPPORTED_CONFIG_VERSION", "Recreate engines and improve.strategies manually for AKM 0.9.0; profile-based configuration is not translated automatically.");
148
- }
148
+ const parsedRaw = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
149
149
  // #852 (following #815): lift legacy `extraParams` keys — e.g.
150
150
  // `reasoning_effort`, a documented 0.9.1 workaround — onto the first-class
151
151
  // engine field they now shadow, before the protected-key check in
@@ -456,20 +456,24 @@ function validateCommon(value) {
456
456
  }
457
457
  }
458
458
  }
459
- /** Decode the persisted public result contract. */
460
- export function decodeImproveResult(input) {
461
- let parsed = input;
462
- if (typeof input === "string") {
463
- try {
464
- parsed = JSON.parse(input);
465
- }
466
- catch {
467
- fail("not valid JSON");
468
- }
469
- }
470
- if (!isRecord(parsed))
471
- fail("root must be an object");
472
- if (parsed.schemaVersion === 2) {
459
+ /**
460
+ * Per-`schemaVersion` decoders for the persisted `improve_runs.result_json`
461
+ * contract, keyed by the literal `schemaVersion` value each accepts.
462
+ *
463
+ * #866 item 5 / read-shim policy (same shape as the version router in
464
+ * src/tasks/source/parse-task-source.ts, which this mirrors): every
465
+ * `schemaVersion` this build understands gets its own entry here, so
466
+ * decoding never regresses to "reject every row" the day the version bumps
467
+ * — it only stops accepting the ONE version that changed shape. All 1,539
468
+ * live rows today are v2; when a v3 shape is introduced, add a `3` entry
469
+ * (and, if v2 rows must keep reading under the newer runtime, keep this `2`
470
+ * entry as-is — every caller already skip-and-counts a decode failure, so a
471
+ * genuinely unreadable old row degrades to "excluded from metrics", never a
472
+ * crash). Do not remove the `2` entry when adding `3` unless the schema
473
+ * bump is known to be a strict superset callers can validate with it.
474
+ */
475
+ const SCHEMA_DECODERS = {
476
+ 2: (parsed) => {
473
477
  requireExactFields(parsed, V2_FIELDS);
474
478
  validateCommon(parsed);
475
479
  if (typeof parsed.strategy !== "string" || parsed.strategy.length === 0) {
@@ -482,6 +486,23 @@ export function decodeImproveResult(input) {
482
486
  envelope: parsed,
483
487
  strategy: parsed.strategy,
484
488
  };
489
+ },
490
+ };
491
+ /** Decode the persisted public result contract. */
492
+ export function decodeImproveResult(input) {
493
+ let parsed = input;
494
+ if (typeof input === "string") {
495
+ try {
496
+ parsed = JSON.parse(input);
497
+ }
498
+ catch {
499
+ fail("not valid JSON");
500
+ }
485
501
  }
502
+ if (!isRecord(parsed))
503
+ fail("root must be an object");
504
+ const decoder = typeof parsed.schemaVersion === "number" ? SCHEMA_DECODERS[parsed.schemaVersion] : undefined;
505
+ if (decoder)
506
+ return decoder(parsed);
486
507
  fail(`unsupported schemaVersion: ${String(parsed.schemaVersion)}`);
487
508
  }
@@ -7,7 +7,6 @@ import path from "node:path";
7
7
  import { parseFrontmatter } from "../core/asset/frontmatter.js";
8
8
  import { UsageError } from "../core/errors.js";
9
9
  import { createExecutionSourceIdentity } from "./source.js";
10
- export const DEFAULT_GUARDED_SOURCE_MAX_BYTES = 1024 * 1024;
11
10
  function errorMessage(cause) {
12
11
  return cause instanceof Error ? cause.message : String(cause);
13
12
  }
@@ -71,10 +70,6 @@ function captureRecord(sourcePathInput, containmentRootInput, options = {}) {
71
70
  const sourcePath = path.resolve(sourcePathInput);
72
71
  const { containmentRoot, containmentRealPath, containmentStat } = requireContainmentRoot(containmentRootInput);
73
72
  const lexicalRelative = containedRelative(containmentRoot, sourcePath, false);
74
- const maxBytes = options.maxBytes ?? DEFAULT_GUARDED_SOURCE_MAX_BYTES;
75
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
76
- throw new UsageError("Guarded source byte limit must be a non-negative safe integer.", "INVALID_FLAG_VALUE");
77
- }
78
73
  const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
79
74
  let descriptor;
80
75
  try {
@@ -83,9 +78,6 @@ function captureRecord(sourcePathInput, containmentRootInput, options = {}) {
83
78
  if (!before.isFile()) {
84
79
  throw new UsageError(`${sourcePath} is not a regular guarded source file.`, "INVALID_FLAG_VALUE");
85
80
  }
86
- if (before.size > BigInt(maxBytes)) {
87
- throw new UsageError(`${sourcePath} exceeds the guarded source size limit (1 MiB; ${maxBytes} bytes).`, "INVALID_FLAG_VALUE");
88
- }
89
81
  const bytes = fs.readFileSync(descriptor);
90
82
  const after = fs.fstatSync(descriptor, { bigint: true });
91
83
  if (!sameBigIntStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
@@ -127,7 +119,6 @@ function captureRecord(sourcePathInput, containmentRootInput, options = {}) {
127
119
  ...(options.identity ? { identity: options.identity } : {}),
128
120
  }),
129
121
  stat: numberStat(before),
130
- maxBytes,
131
122
  };
132
123
  }
133
124
  catch (cause) {
@@ -402,7 +393,6 @@ export class GuardedExecutionSourceCollector {
402
393
  try {
403
394
  current = captureRecord(record.source.sourcePath, record.source.containmentRoot, {
404
395
  authored: record.source.authored,
405
- maxBytes: record.maxBytes,
406
396
  ...(record.source.identity ? { identity: record.source.identity } : {}),
407
397
  }).source;
408
398
  }
@@ -10,10 +10,6 @@ import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
10
10
  import { resolveUniqueWorkflowSource, workflowNameForConceptId, } from "../../workflows/source-files.js";
11
11
  import { buildFileContext } from "../walk/file-context.js";
12
12
  const CONTENT_READ_REQUIRED = Symbol("adapter ownership probe requires content");
13
- const OWNER_SCAN_MAX_DIRECTORIES = 4_096;
14
- const OWNER_SCAN_MAX_FILES = 16_384;
15
- const OWNER_SCAN_SKIP_DIRECTORIES = new Set([".git", "node_modules", "bin", ".cache"]);
16
- const OWNER_SCAN_SKIP_FILES = new Set([".stash.json", ".gitignore", ".gitattributes"]);
17
13
  export class AdapterConceptOwnershipError extends UsageError {
18
14
  }
19
15
  export class AdapterConceptCollisionError extends AdapterConceptOwnershipError {
@@ -24,13 +20,6 @@ export class AdapterConceptCollisionError extends AdapterConceptOwnershipError {
24
20
  Object.setPrototypeOf(this, new.target.prototype);
25
21
  }
26
22
  }
27
- export class AdapterConceptScanError extends AdapterConceptOwnershipError {
28
- constructor(adapterId, sourcePath, detail) {
29
- super(`Adapter "${adapterId}" could not safely enumerate ${sourcePath}: ${detail}.`, "INVALID_FLAG_VALUE");
30
- this.name = "AdapterConceptScanError";
31
- Object.setPrototypeOf(this, new.target.prototype);
32
- }
33
- }
34
23
  function normalizedConceptId(conceptId) {
35
24
  const normalized = conceptId.replaceAll("\\", "/");
36
25
  if (!normalized ||
@@ -128,79 +117,6 @@ function claimsWithoutContent(adapter, component, owner) {
128
117
  throw error;
129
118
  }
130
119
  }
131
- function adapterPathContext(root, authoredPath) {
132
- const file = buildFileContext(root, authoredPath);
133
- return {
134
- absPath: file.absPath,
135
- relPath: file.relPath,
136
- ext: file.ext,
137
- fileName: file.fileName,
138
- parentDir: file.parentDir,
139
- parentDirAbs: file.parentDirAbs,
140
- ancestorDirs: file.ancestorDirs,
141
- stashRoot: file.stashRoot,
142
- };
143
- }
144
- function scanRegularAuthoredPaths(sourcePath, realRoot, adapterId) {
145
- const paths = [];
146
- const stack = [path.resolve(sourcePath)];
147
- let directories = 0;
148
- let files = 0;
149
- while (stack.length > 0) {
150
- const current = stack.pop();
151
- if (!current)
152
- continue;
153
- directories++;
154
- if (directories > OWNER_SCAN_MAX_DIRECTORIES) {
155
- throw new AdapterConceptScanError(adapterId, sourcePath, `directory limit ${OWNER_SCAN_MAX_DIRECTORIES} exceeded`);
156
- }
157
- let entries;
158
- try {
159
- entries = fs
160
- .readdirSync(current, { withFileTypes: true })
161
- .sort((left, right) => comparePaths(left.name, right.name));
162
- }
163
- catch (error) {
164
- throw new AdapterConceptScanError(adapterId, sourcePath, `cannot read ${current}: ${String(error)}`);
165
- }
166
- for (let index = entries.length - 1; index >= 0; index--) {
167
- const entry = entries[index];
168
- if (!entry)
169
- continue;
170
- const authoredPath = path.join(current, entry.name);
171
- if (entry.isSymbolicLink())
172
- continue;
173
- if (entry.isDirectory()) {
174
- if (OWNER_SCAN_SKIP_DIRECTORIES.has(entry.name) || entry.name.startsWith("."))
175
- continue;
176
- stack.push(authoredPath);
177
- continue;
178
- }
179
- if (!entry.isFile() || OWNER_SCAN_SKIP_FILES.has(entry.name))
180
- continue;
181
- files++;
182
- if (files > OWNER_SCAN_MAX_FILES) {
183
- throw new AdapterConceptScanError(adapterId, sourcePath, `file limit ${OWNER_SCAN_MAX_FILES} exceeded`);
184
- }
185
- let realPath;
186
- try {
187
- realPath = fs.realpathSync(authoredPath);
188
- }
189
- catch {
190
- continue;
191
- }
192
- if (isWithin(realPath, realRoot))
193
- paths.push(authoredPath);
194
- }
195
- }
196
- return paths.sort(comparePaths);
197
- }
198
- function scannedReadCandidates(adapter, component, sourcePath, realRoot, conceptId) {
199
- const recognizePathCandidates = adapter.recognizePathCandidates;
200
- if (!recognizePathCandidates)
201
- return [];
202
- return scanRegularAuthoredPaths(sourcePath, realRoot, adapter.id).flatMap((authoredPath) => recognizePathCandidates(component, adapterPathContext(sourcePath, authoredPath)).flatMap((candidateConceptId) => candidateConceptId === conceptId ? [{ path: authoredPath, conceptId: candidateConceptId }] : []));
203
- }
204
120
  /**
205
121
  * Resolve one adapter/component's exact physical concept owner without reading
206
122
  * authored bytes. Native workflow arbitration is reused verbatim; every other
@@ -240,11 +156,12 @@ export function resolveAdapterConceptOwner(sourcePath, adapterId, conceptId) {
240
156
  ownersByIdentity.set(`${path.resolve(workflowOwner.path)}\0${resolutionConceptId}`, workflowOwner);
241
157
  }
242
158
  }
243
- const directSpellings = (adapter.readCandidates?.(component, resolutionConceptId) ?? []).flatMap((candidate) => candidateSpellings(candidate, sourcePath, realRoot));
244
- const spellings = [
245
- ...directSpellings,
246
- ...scannedReadCandidates(adapter, component, sourcePath, realRoot, resolutionConceptId),
247
- ];
159
+ // Closed-form candidate set (#857): `readCandidates` inverts the conceptId
160
+ // directly into every physical spelling that could own it (no bundle walk
161
+ // — see each adapter's own doc comment for its candidate classes), and
162
+ // `candidateSpellings` adds case-only siblings via one `readdirSync` of
163
+ // each candidate's own parent directory.
164
+ const spellings = (adapter.readCandidates?.(component, resolutionConceptId) ?? []).flatMap((candidate) => candidateSpellings(candidate, sourcePath, realRoot));
248
165
  const inspected = [
249
166
  ...new Map(spellings.map((candidate) => [`${path.resolve(candidate.path)}\0${candidate.conceptId}`, candidate])).values(),
250
167
  ]
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
7
7
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
8
  import { asNonEmptyString } from "../../core/common.js";
9
- import { isVerbose, warn } from "../../core/warn.js";
9
+ import { isVerbose, warn, warnVerbose } from "../../core/warn.js";
10
10
  export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
11
11
  // ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
12
12
  /**
@@ -1119,7 +1119,7 @@ function stripMarkdownLinkDestinations(text, nesting = 0) {
1119
1119
  * secret/env/session bytes; that policy is enforced at the adapter metadata
1120
1120
  * boundary below.
1121
1121
  */
1122
- export function projectMarkdownContent(body) {
1122
+ export function projectMarkdownContent(body, truncationInfo) {
1123
1123
  const lines = body.split(/\r?\n/);
1124
1124
  const innerBlock = findInnerFrontmatterBlock(lines);
1125
1125
  const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
@@ -1168,6 +1168,8 @@ export function projectMarkdownContent(body) {
1168
1168
  const text = projected.join(" ").replace(/\s+/g, " ").trim();
1169
1169
  if (!text)
1170
1170
  return undefined;
1171
+ if (truncationInfo)
1172
+ truncationInfo.truncated = text.length > MARKDOWN_CONTENT_MAX_CHARS;
1171
1173
  return truncateUnicodeSafe(text, MARKDOWN_CONTENT_MAX_CHARS);
1172
1174
  }
1173
1175
  // ── Metadata Generation ─────────────────────────────────────────────────────
@@ -1212,9 +1214,15 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1212
1214
  // Native Markdown has one bounded low-weight body projection. Sensitive
1213
1215
  // types and raw session/checkpoint material never cross this boundary.
1214
1216
  if (entry.type !== "env" && entry.type !== "session" && !hasSessionMemoryMarker(parsed.data, parsed.content)) {
1215
- const contentProjection = projectMarkdownContent(parsed.content);
1216
- if (contentProjection)
1217
+ const truncationInfo = { truncated: false };
1218
+ const contentProjection = projectMarkdownContent(parsed.content, truncationInfo);
1219
+ if (contentProjection) {
1217
1220
  entry.content = contentProjection;
1221
+ if (truncationInfo.truncated) {
1222
+ entry.contentTruncated = true;
1223
+ warnVerbose(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
1224
+ }
1225
+ }
1218
1226
  }
1219
1227
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
1220
1228
  if (entry.type === "command") {
@@ -58,6 +58,8 @@ export function indexDocumentToStashEntry(doc) {
58
58
  entry.tags = doc.tags;
59
59
  if (doc.content !== undefined)
60
60
  entry.content = doc.content;
61
+ if (doc.contentTruncated !== undefined)
62
+ entry.contentTruncated = doc.contentTruncated;
61
63
  if (doc.ownsPresentation !== undefined)
62
64
  entry.ownsPresentation = doc.ownsPresentation;
63
65
  if (doc.updated !== undefined)
@@ -785,6 +785,10 @@ export async function buildDbHit(input) {
785
785
  ...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
786
786
  ...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
787
787
  ...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
788
+ // Which stage of the progressive AND->OR lexical ladder produced this
789
+ // hit. Omitted when the hit has no FTS component (pure-semantic hybrid
790
+ // contribution).
791
+ ...(input.lexicalMatch ? { matchStage: input.lexicalMatch } : {}),
788
792
  };
789
793
  attachDbHitAttribution(hit, input);
790
794
  if (input.entry.derivedFrom) {
@@ -825,6 +829,8 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted, graphBoost, lexicalM
825
829
  ];
826
830
  if (lexicalMatch === "relaxed")
827
831
  reasons.push("lexical recovery after strict query returned no hits");
832
+ if (lexicalMatch === "prefix")
833
+ reasons.push("prefix match after strict query returned no hits");
828
834
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
829
835
  const queryLower = query.toLowerCase().trim();
830
836
  const name = entry.name.toLowerCase();
@@ -1,6 +1,16 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Per-field search text extraction for FTS5 indexing.
6
+ *
7
+ * Extracted from indexer.ts to break the circular dependency:
8
+ * db.ts -> indexer.ts -> db.ts
9
+ *
10
+ * This module imports only from metadata.ts (for the IndexDocument type),
11
+ * so it can be safely imported by both db.ts and indexer.ts.
12
+ */
13
+ import { warnVerbose } from "../../core/warn.js";
4
14
  /** Structured metadata plus bounded body text supplied to embedding providers. */
5
15
  export const SEARCH_TEXT_MAX_CHARS = 8_192;
6
16
  /**
@@ -80,12 +90,17 @@ export function buildSearchText(entry) {
80
90
  const structured = [fields.name, fields.description, fields.tags, fields.hints]
81
91
  .filter((field) => field.length > 0)
82
92
  .join(" ");
83
- if (structured.length >= SEARCH_TEXT_MAX_CHARS)
93
+ if (structured.length >= SEARCH_TEXT_MAX_CHARS) {
94
+ warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars (content dropped entirely)`);
84
95
  return truncateUnicodeSafe(structured, SEARCH_TEXT_MAX_CHARS);
96
+ }
85
97
  if (!fields.content)
86
98
  return structured;
87
99
  const separator = structured ? " " : "";
88
100
  const remaining = SEARCH_TEXT_MAX_CHARS - structured.length - separator.length;
101
+ if (fields.content.length > remaining) {
102
+ warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars`);
103
+ }
89
104
  return `${structured}${separator}${truncateUnicodeSafe(fields.content, remaining)}`;
90
105
  }
91
106
  function truncateUnicodeSafe(text, maxChars) {
@@ -270,28 +270,6 @@ function matchResultForFact(fact) {
270
270
  ...(fact.meta ? { meta: fact.meta } : {}),
271
271
  };
272
272
  }
273
- /**
274
- * Every smart-Markdown result possible from path fields alone. The actual
275
- * classifier above returns only facts from this shared table, so owner
276
- * discovery can conservatively model a byte request without reading bytes.
277
- */
278
- export function smartMdPathCandidates(ctx) {
279
- if (ctx.ext !== ".md" || ctx.ancestorDirs.includes("secrets"))
280
- return [];
281
- const facts = isTypedDirDocFile(ctx.fileName)
282
- ? [SMART_MD_FACTS.knowledge]
283
- : [
284
- SMART_MD_FACTS.workflow,
285
- SMART_MD_FACTS.toolsAgent,
286
- SMART_MD_FACTS.command,
287
- SMART_MD_FACTS.modelAgent,
288
- SMART_MD_FACTS.knowledge,
289
- ];
290
- return facts.flatMap((fact) => {
291
- const result = matchResultForFact(fact);
292
- return result ? [result] : [];
293
- });
294
- }
295
273
  // ---------------------------------------------------------------------------
296
274
  // Public matchers (API unchanged)
297
275
  // ---------------------------------------------------------------------------
@@ -275,7 +275,20 @@ export function shapeSearchHit(hit, detail) {
275
275
  // visible without forcing callers up to `--detail full`. Optional
276
276
  // `quality` (v1 spec §4.2) is also surfaced when present so callers
277
277
  // can see why a `proposed` entry showed up under `--include-proposed`.
278
- const shaped = capDescription(pickFields(hit, ["type", "name", "description", "action", "score", "estimatedTokens", "warnings", "quality"]), NORMAL_DESCRIPTION_LIMIT);
278
+ // `matchStage` (issue #856) surfaces which stage of the progressive
279
+ // AND->OR lexical ladder produced the hit; cheap compact enum, worth
280
+ // showing without requiring `--detail full`.
281
+ const shaped = capDescription(pickFields(hit, [
282
+ "type",
283
+ "name",
284
+ "description",
285
+ "action",
286
+ "score",
287
+ "estimatedTokens",
288
+ "warnings",
289
+ "quality",
290
+ "matchStage",
291
+ ]), NORMAL_DESCRIPTION_LIMIT);
279
292
  if (Array.isArray(hit.keys) && hit.keys.length > 0)
280
293
  shaped.keys = hit.keys;
281
294
  return shaped;
@@ -296,6 +309,11 @@ export function shapeSearchHitForAgent(hit) {
296
309
  "score",
297
310
  "estimatedTokens",
298
311
  "keys",
312
+ // Issue #856: which stage of the progressive AND->OR lexical ladder
313
+ // produced this hit. Agents need this to gauge how much to trust a
314
+ // hit (a strict-AND match is stronger signal than an OR-fallback
315
+ // recovery match) without going to `--detail full`.
316
+ "matchStage",
299
317
  ]);
300
318
  if (picked.editable !== false)
301
319
  delete picked.editHint;
@@ -4,6 +4,15 @@
4
4
  // #484: stamp schemaVersion + shape discriminator on passthrough envelopes so
5
5
  // third-party consumers can pin a schema version and dispatch on shape uniformly.
6
6
  // Idempotent — never overwrites an existing schemaVersion or shape field.
7
+ //
8
+ // Builds a shallow copy rather than mutating `result` in place: several
9
+ // command results (e.g. `akm task sync --dry-run`'s `SchedulerPlanPreview`,
10
+ // see src/tasks/scheduler-sync-preview.ts) are deliberately `Object.freeze`d
11
+ // by their producer as an immutability guarantee, and an in-place `obj.shape
12
+ // = …` assignment throws ("Attempting to define property on object that is
13
+ // not extensible") the moment it hits one. Copying tolerates both frozen and
14
+ // mutable inputs uniformly, and `output()` never uses the result's identity
15
+ // past this call, so a copy is safe here.
7
16
  function makeStampHandler(command) {
8
17
  return (result) => {
9
18
  if (result === null || result === undefined)
@@ -11,11 +20,13 @@ function makeStampHandler(command) {
11
20
  if (typeof result !== "object" || Array.isArray(result))
12
21
  return result;
13
22
  const obj = result;
14
- if (obj.shape === undefined)
15
- obj.shape = command;
16
- if (obj.schemaVersion === undefined)
17
- obj.schemaVersion = 1;
18
- return obj;
23
+ if (obj.shape !== undefined && obj.schemaVersion !== undefined)
24
+ return obj;
25
+ return {
26
+ ...obj,
27
+ shape: obj.shape ?? command,
28
+ schemaVersion: obj.schemaVersion ?? 1,
29
+ };
19
30
  };
20
31
  }
21
32
  const PASSTHROUGH_COMMANDS = [
@@ -55,8 +66,10 @@ const PASSTHROUGH_COMMANDS = [
55
66
  "task-doctor",
56
67
  "task-explain",
57
68
  "task-history",
69
+ "task-prune",
58
70
  "task-run",
59
71
  "task-sync",
72
+ "task-sync-dry-run",
60
73
  "update",
61
74
  "upgrade",
62
75
  "workflow-abandon",
@@ -270,6 +270,10 @@ export function formatSearchPlain(r, detail) {
270
270
  // Optional v1 spec §4.2 quality marker (e.g. "curated" / "proposed").
271
271
  if (typeof hit.quality === "string" && hit.quality)
272
272
  lines.push(` quality: ${hit.quality}`);
273
+ // Issue #856: which stage of the progressive AND->OR lexical ladder
274
+ // produced this hit ("exact" | "prefix" | "relaxed").
275
+ if (typeof hit.matchStage === "string" && hit.matchStage)
276
+ lines.push(` matchStage: ${hit.matchStage}`);
273
277
  // Surface optional hit-level warnings (v1 spec §4.2).
274
278
  if (Array.isArray(hit.warnings) && hit.warnings.length > 0) {
275
279
  lines.push(` warnings: ${hit.warnings.join("; ")}`);
@@ -117,8 +117,8 @@ export async function nodePinnedRequestHelperMain(readRequest, http, https, isIP
117
117
  };
118
118
  try {
119
119
  const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
120
- if (!Number.isInteger(nodeMajor) || nodeMajor < 24) {
121
- throw new Error(`The pinned registry helper requires Node.js >= 24; found ${process.versions.node}`);
120
+ if (!Number.isInteger(nodeMajor) || nodeMajor < 22) {
121
+ throw new Error(`The pinned registry helper requires Node.js >= 22; found ${process.versions.node}`);
122
122
  }
123
123
  const { addressFamily, bodyPresent, headers, initialBody, iterator, method, pinnedAddress, timeoutMs, url } = await readRequest(isIP);
124
124
  const hostname = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
@@ -233,7 +233,7 @@ async function requestWithNodeHelper(url, address, prepared, init, timeoutMs, ex
233
233
  }
234
234
  function resolveNodeExecutable(override) {
235
235
  if (override === null) {
236
- throw new RegistryPinnedTransportError("Pinned registry networking under Bun requires Node.js >= 24 on PATH; no Node executable is available");
236
+ throw new RegistryPinnedTransportError("Pinned registry networking under Bun requires Node.js >= 22 on PATH; no Node executable is available");
237
237
  }
238
238
  const candidates = override === undefined
239
239
  ? (process.env.PATH ?? "")
@@ -254,10 +254,10 @@ function resolveNodeExecutable(override) {
254
254
  return resolved;
255
255
  }
256
256
  catch {
257
- // Try the next PATH entry. The helper itself enforces Node >= 24.
257
+ // Try the next PATH entry. The helper itself enforces Node >= 22.
258
258
  }
259
259
  }
260
- throw new RegistryPinnedTransportError("Pinned registry networking under Bun requires Node.js >= 24 on PATH. Install Node.js or run akm with Node.");
260
+ throw new RegistryPinnedTransportError("Pinned registry networking under Bun requires Node.js >= 22 on PATH. Install Node.js or run akm with Node.");
261
261
  }
262
262
  function isSupportedNodeExecutable(executable) {
263
263
  const result = spawnSync(executable, ["--version"], {
@@ -272,14 +272,14 @@ function isSupportedNodeExecutable(executable) {
272
272
  return false;
273
273
  const match = /^v(\d+)\./.exec(result.stdout.trim());
274
274
  const major = match?.[1];
275
- return major !== undefined && Number.parseInt(major, 10) >= 24;
275
+ return major !== undefined && Number.parseInt(major, 10) >= 22;
276
276
  }
277
277
  function assertNodeRuntimeVersion() {
278
278
  if (process.versions.bun)
279
279
  return;
280
280
  const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
281
- if (!Number.isInteger(major) || major < 24) {
282
- throw new RegistryPinnedTransportError(`Pinned registry networking requires Node.js >= 24; found ${process.versions.node}`);
281
+ if (!Number.isInteger(major) || major < 22) {
282
+ throw new RegistryPinnedTransportError(`Pinned registry networking requires Node.js >= 22; found ${process.versions.node}`);
283
283
  }
284
284
  }
285
285
  function createHelperArtifacts(ca) {