akm-cli 0.9.2 → 0.9.4

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 (46) hide show
  1. package/CHANGELOG.md +110 -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/preparation.js +8 -1
  12. package/dist/commands/lint/index.js +3 -7
  13. package/dist/commands/tasks/tasks-cli.js +28 -3
  14. package/dist/commands/tasks/tasks.js +29 -1
  15. package/dist/core/adapter/adapters/akm-adapter.js +21 -14
  16. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  17. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  18. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  19. package/dist/core/adapter/recognize-match.js +1 -20
  20. package/dist/core/asset/asset-placement.js +21 -2
  21. package/dist/core/common.js +21 -1
  22. package/dist/core/config/config.js +19 -3
  23. package/dist/core/extra-params.js +115 -1
  24. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  25. package/dist/indexer/search/db-search.js +6 -0
  26. package/dist/indexer/walk/matchers.js +0 -22
  27. package/dist/output/shapes/helpers.js +19 -1
  28. package/dist/output/shapes/passthrough.js +1 -0
  29. package/dist/output/text/command-format.js +4 -0
  30. package/dist/registry/pinned-request-helper.js +2 -2
  31. package/dist/registry/pinned-transport.js +6 -6
  32. package/dist/scripts/akm-migrate-node.js +12698 -12530
  33. package/dist/scripts/akm-migrate.js +12698 -12530
  34. package/dist/storage/repositories/proposals-repository.js +33 -1
  35. package/dist/storage/repositories/task-history-repository.js +22 -10
  36. package/dist/tasks/run/run-native-task.js +28 -1
  37. package/dist/tasks/run/task-history.js +23 -3
  38. package/dist/tasks/scheduler-sync-preview.js +43 -0
  39. package/dist/tasks/scheduler-sync.js +1 -0
  40. package/dist/tasks/source/bounded-document.js +1 -1
  41. package/dist/tasks/source/parse-task-source.js +77 -11
  42. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  43. package/dist/tasks/source/task-to-v3.js +500 -0
  44. package/dist/tasks/source/task-to-v4.js +467 -0
  45. package/docs/reference/cli.md +8 -3
  46. package/package.json +4 -4
@@ -1,7 +1,7 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher, smartMdPathCandidates, } from "../../indexer/walk/matchers.js";
4
+ import { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher } from "../../indexer/walk/matchers.js";
5
5
  /**
6
6
  * The four builtin matchers, in registration order. The array index IS the
7
7
  * registration index `runMatchers` uses for tie-breaking. (The `wiki` matcher
@@ -46,22 +46,3 @@ export function recognizeMatch(file) {
46
46
  }
47
47
  return winningMatch(hits);
48
48
  }
49
- /**
50
- * Every AKM matcher winner possible from path fields alone. The three
51
- * path-only matchers run exactly as production does; each possible result of
52
- * the shared smart-Markdown fact table is then arbitrated at its real index.
53
- */
54
- export function recognizePathCandidateMatches(file) {
55
- const fileContext = file;
56
- const pathHits = [extensionMatcher, directoryMatcher, parentDirHintMatcher].flatMap((matcher, index) => {
57
- const result = matcher(fileContext);
58
- return result ? [{ result, index }] : [];
59
- });
60
- const smartCandidates = smartMdPathCandidates(file);
61
- const variants = smartCandidates.length > 0 ? smartCandidates : [undefined];
62
- const winners = variants.flatMap((smart) => {
63
- const winner = winningMatch(smart ? [...pathHits, { result: smart, index: 3 }] : [...pathHits]);
64
- return winner ? [winner] : [];
65
- });
66
- return [...new Map(winners.map((winner) => [`${winner.type}\0${winner.renderer}`, winner])).values()];
67
- }
@@ -151,10 +151,10 @@ const BUILTIN_PLACEMENT_SPECS = {
151
151
  isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".yml",
152
152
  toCanonicalName: (typeRoot, filePath) => {
153
153
  const rel = toPosix(path.relative(typeRoot, filePath));
154
- return rel.endsWith(".yml") ? rel.slice(0, -4) : rel;
154
+ return rel.toLowerCase().endsWith(".yml") ? rel.slice(0, -4) : rel;
155
155
  },
156
156
  toAssetPath: (typeRoot, name) => {
157
- const withExt = name.endsWith(".yml") ? name : `${name}.yml`;
157
+ const withExt = name.toLowerCase().endsWith(".yml") ? name : `${name}.yml`;
158
158
  return path.join(typeRoot, withExt);
159
159
  },
160
160
  },
@@ -241,3 +241,22 @@ export function assetPathForName(assetType, typeRoot, name) {
241
241
  throw new Error(`Unknown asset type: "${assetType}"`);
242
242
  return spec.toAssetPath(typeRoot, name);
243
243
  }
244
+ /**
245
+ * Every physical path spelling that could own `name`, closed-form (#857).
246
+ * `toAssetPath` is a function, so it can only pick ONE spelling — but `env`'s
247
+ * "default" alias is genuinely dual-owned: both `<dir>/.env` and
248
+ * `<dir>/default.env` derive the same canonical name (`toCanonicalName`
249
+ * above), so a physical-owner lookup must consider both without reading
250
+ * either file. Every other placement type has exactly one inverse spelling.
251
+ */
252
+ export function assetPathCandidatesForName(assetType, typeRoot, name) {
253
+ const primary = assetPathForName(assetType, typeRoot, name);
254
+ if (assetType !== "env")
255
+ return [primary];
256
+ const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
257
+ if (base === undefined)
258
+ return [primary];
259
+ const dotForm = path.join(typeRoot, base, ".env");
260
+ const namedForm = path.join(typeRoot, base, "default.env");
261
+ return [...new Set([primary, dotForm, namedForm])];
262
+ }
@@ -5,7 +5,7 @@ import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { ConfigError } from "./errors.js";
8
- import { getConfigPath, getDefaultStashDir } from "./paths.js";
8
+ import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryIndexCacheDir } from "./paths.js";
9
9
  // ── Constants ───────────────────────────────────────────────────────────────
10
10
  // Moved to the platform leaf so paths.ts can use it without a common↔paths
11
11
  // cycle (chunk-8 WI-8.6, DoD 11); re-exported here for the existing surface.
@@ -382,6 +382,26 @@ export function isContainedRelativePath(value) {
382
382
  export function isWithin(candidate, root) {
383
383
  return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
384
384
  }
385
+ /**
386
+ * True when `filePath` sits inside akm's OWN resolved registry-cache
387
+ * directories (`<cache>/registry`, `<cache>/registry-index` —
388
+ * {@link getRegistryCacheDir}/{@link getRegistryIndexCacheDir}), the
389
+ * read-only installed-source/registry-index copies that lint (and `--fix`)
390
+ * must never touch.
391
+ *
392
+ * This is the single source of truth for that exclusion — it replaces what
393
+ * used to be three independent unanchored substring checks
394
+ * (`posixPath.includes("/.cache/") || posixPath.includes("/registry/")`).
395
+ * That check matched ANY path merely containing the literal text `.cache` or
396
+ * `registry` anywhere in it — a normal XDG `~/.cache/...` user bundle, or a
397
+ * CI workspace checked out under a `.cache`-named directory, tripped it and
398
+ * silently got zero lint findings. Using {@link isWithin} (realpath +
399
+ * containment, not a string search) fixes that while still excluding the
400
+ * real cache content the check was meant to skip.
401
+ */
402
+ export function isAkmRegistryCachePath(filePath) {
403
+ return isWithin(filePath, getRegistryCacheDir()) || isWithin(filePath, getRegistryIndexCacheDir());
404
+ }
385
405
  /**
386
406
  * {@link isWithin} for callers that must not block the event loop (e.g. the
387
407
  * workflow exec dispatch path, which runs once per fan-out unit). Same
@@ -4,6 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { ConfigError } from "../errors.js";
7
+ import { liftLegacyEngineExtraParams } from "../extra-params.js";
7
8
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
8
9
  import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
9
10
  import { bundlesToSourceEntries } from "./config-sources.js";
@@ -141,14 +142,29 @@ export function acquireConfigReadFence() {
141
142
  * canonical shape before defaults are merged.
142
143
  */
143
144
  export function parseAndValidateConfigText(text, sourcePath) {
144
- const raw = parseConfigText(text, sourcePath);
145
- if (raw.configVersion !== CURRENT_CONFIG_VERSION) {
145
+ const parsedRaw = parseConfigText(text, sourcePath);
146
+ if (parsedRaw.configVersion !== CURRENT_CONFIG_VERSION) {
146
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.");
147
148
  }
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);
155
+ const where = sourcePath ? ` at ${sourcePath}` : "";
156
+ if (conflicts.length > 0) {
157
+ const lines = conflicts
158
+ .map((c) => ` - engines.${c.engine}.extraParams.${c.key} (${JSON.stringify(c.extraParamsValue)}) conflicts with engines.${c.engine}.${c.field} (${JSON.stringify(c.fieldValue)})`)
159
+ .join("\n");
160
+ 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
+ }
162
+ 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 - ")}`);
164
+ }
148
165
  const parsed = AkmConfigSchema.safeParse(raw);
149
166
  if (!parsed.success) {
150
167
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
151
- const where = sourcePath ? ` at ${sourcePath}` : "";
152
168
  throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
153
169
  }
154
170
  const merged = deepMergeConfig(DEFAULT_CONFIG, parsed.data);
@@ -25,6 +25,46 @@ export const EXTRA_PARAMS_CREDENTIAL_KEYS = [
25
25
  ];
26
26
  const PROTECTED_TOP_LEVEL_KEYS = new Set(EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS);
27
27
  const CREDENTIAL_KEYS = new Set(EXTRA_PARAMS_CREDENTIAL_KEYS);
28
+ // ── Protected-key remedies (#852) ───────────────────────────────────────────
29
+ //
30
+ // `EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS` rightly stops a provider extra from
31
+ // shadowing an AKM-managed field, but naming the rule without naming the fix
32
+ // leaves the reader stuck (#852). Every protected key gets a one-line remedy
33
+ // baked into its issue message; keys with a genuine scalar first-class field
34
+ // are also eligible for the automatic config-load lift below.
35
+ /** normalizeExtraParamKey(key) -> the first-class engine field it shadows. */
36
+ const LEGACY_EXTRA_PARAMS_FIELD = {
37
+ model: "model",
38
+ temperature: "temperature",
39
+ maxtokens: "maxTokens",
40
+ enablethinking: "enableThinking",
41
+ reasoningeffort: "reasoningEffort",
42
+ };
43
+ /**
44
+ * Subset of {@link LEGACY_EXTRA_PARAMS_FIELD} that {@link liftLegacyEngineExtraParams}
45
+ * will move onto the first-class field automatically. `model` is deliberately
46
+ * excluded: unlike the others it was never a "no first-class field yet"
47
+ * workaround (`model` has always been required on an LLM engine), so a
48
+ * mismatch is far more likely a genuine mistake than a stale 0.9.1 config —
49
+ * it stays a hard rejection rather than being silently reinterpreted.
50
+ */
51
+ const LIFTABLE_EXTRA_PARAMS_KEYS = new Set(["temperature", "maxtokens", "enablethinking", "reasoningeffort"]);
52
+ /** Remedies for protected keys with no scalar first-class field to lift onto. */
53
+ const EXTRA_PARAMS_NO_FIELD_REMEDY = {
54
+ messages: "AKM builds the request messages internally — remove it from extraParams",
55
+ responseformat: "AKM controls the response format internally — remove it from extraParams",
56
+ stream: "AKM controls streaming internally — remove it from extraParams",
57
+ streamoptions: "AKM controls streaming internally — remove it from extraParams",
58
+ chattemplatekwargs: "set engines.<name>.enableThinking instead of chat_template_kwargs.enable_thinking",
59
+ };
60
+ function protectedKeyRemedy(normalized) {
61
+ const field = LEGACY_EXTRA_PARAMS_FIELD[normalized];
62
+ if (field) {
63
+ const note = normalized === "reasoningeffort" ? " (moved to a first-class field in 0.9.2)" : "";
64
+ return `set engines.<name>.${field} instead${note}`;
65
+ }
66
+ return EXTRA_PARAMS_NO_FIELD_REMEDY[normalized];
67
+ }
28
68
  export function normalizeExtraParamKey(key) {
29
69
  return key.toLowerCase().replace(/[^a-z0-9]/g, "");
30
70
  }
@@ -57,7 +97,11 @@ export function validateExtraParams(value) {
57
97
  for (const [key, child] of Object.entries(entry)) {
58
98
  const normalized = normalizeExtraParamKey(key);
59
99
  if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {
60
- issues.push({ path: [key], message: `${key} is protected by AKM` });
100
+ const remedy = protectedKeyRemedy(normalized);
101
+ issues.push({
102
+ path: [key],
103
+ message: remedy ? `${key} is protected by AKM — ${remedy}.` : `${key} is protected by AKM`,
104
+ });
61
105
  }
62
106
  if (CREDENTIAL_KEYS.has(normalized)) {
63
107
  issues.push({ path: [...path, key], message: `${key} cannot carry credentials` });
@@ -72,3 +116,73 @@ export function formatExtraParamsIssue(label, issue) {
72
116
  const suffix = issue.path.map((part) => (typeof part === "number" ? `[${part}]` : `.${part}`)).join("");
73
117
  return `${label}${suffix} ${issue.message}`;
74
118
  }
119
+ function isPlainObject(value) {
120
+ return typeof value === "object" && value !== null && !Array.isArray(value);
121
+ }
122
+ /**
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).
127
+ *
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.
132
+ */
133
+ export function liftLegacyEngineExtraParams(raw) {
134
+ const lifted = [];
135
+ const conflicts = [];
136
+ const rawEngines = raw.engines;
137
+ if (!isPlainObject(rawEngines)) {
138
+ return { config: raw, lifted, conflicts };
139
+ }
140
+ const engines = {};
141
+ let anyEngineChanged = false;
142
+ for (const [name, engineValue] of Object.entries(rawEngines)) {
143
+ if (!isPlainObject(engineValue) || !isPlainObject(engineValue.extraParams)) {
144
+ engines[name] = engineValue;
145
+ continue;
146
+ }
147
+ const engine = { ...engineValue };
148
+ const extraParams = { ...engineValue.extraParams };
149
+ let engineChanged = false;
150
+ for (const [rawKey, value] of Object.entries(engineValue.extraParams)) {
151
+ const normalized = normalizeExtraParamKey(rawKey);
152
+ if (!LIFTABLE_EXTRA_PARAMS_KEYS.has(normalized))
153
+ continue;
154
+ const field = LEGACY_EXTRA_PARAMS_FIELD[normalized];
155
+ if (!field)
156
+ continue;
157
+ const existing = engine[field];
158
+ if (existing !== undefined && existing !== value) {
159
+ conflicts.push({ engine: name, key: rawKey, field, extraParamsValue: value, fieldValue: existing });
160
+ continue;
161
+ }
162
+ delete extraParams[rawKey];
163
+ engineChanged = true;
164
+ if (existing === value) {
165
+ lifted.push(`engines.${name}.extraParams.${rawKey} is redundant — engines.${name}.${field} is already set to the same value; dropped the extraParams entry`);
166
+ continue;
167
+ }
168
+ engine[field] = value;
169
+ lifted.push(`engines.${name}.extraParams.${rawKey} -> engines.${name}.${field}`);
170
+ }
171
+ if (!engineChanged) {
172
+ engines[name] = engineValue;
173
+ continue;
174
+ }
175
+ anyEngineChanged = true;
176
+ if (Object.keys(extraParams).length > 0) {
177
+ engine.extraParams = extraParams;
178
+ }
179
+ else {
180
+ delete engine.extraParams;
181
+ }
182
+ engines[name] = engine;
183
+ }
184
+ if (!anyEngineChanged) {
185
+ return { config: raw, lifted, conflicts };
186
+ }
187
+ return { config: { ...raw, engines }, lifted, conflicts };
188
+ }
@@ -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
  ]
@@ -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();
@@ -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;
@@ -57,6 +57,7 @@ const PASSTHROUGH_COMMANDS = [
57
57
  "task-history",
58
58
  "task-run",
59
59
  "task-sync",
60
+ "task-sync-dry-run",
60
61
  "update",
61
62
  "upgrade",
62
63
  "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) {