akm-cli 0.9.12 → 0.9.14-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/assets/workflows/workflow-template.md +4 -0
  3. package/dist/commands/improve/eligibility.js +27 -15
  4. package/dist/commands/improve/improve.js +1 -0
  5. package/dist/commands/lint/base-linter.js +10 -0
  6. package/dist/commands/proposal/drain.js +48 -6
  7. package/dist/commands/proposal/proposal-cli.js +1 -0
  8. package/dist/commands/read/curate.js +3 -2
  9. package/dist/commands/read/show.js +26 -9
  10. package/dist/core/adapter/adapters/akm-adapter.js +5 -1
  11. package/dist/core/asset/markdown-fragments.js +146 -0
  12. package/dist/core/config/config-walker.js +7 -3
  13. package/dist/core/config/config.js +21 -12
  14. package/dist/core/config/schema/primitives.js +8 -2
  15. package/dist/core/errors.js +2 -0
  16. package/dist/core/lexical-score.js +25 -0
  17. package/dist/core/type-presentation.js +36 -4
  18. package/dist/indexer/index-written-assets.js +4 -0
  19. package/dist/indexer/indexer.js +5 -2
  20. package/dist/indexer/passes/metadata.js +64 -1
  21. package/dist/indexer/scan/doc-to-entry.js +3 -0
  22. package/dist/indexer/scan/drain-dir.js +33 -22
  23. package/dist/indexer/search/db-search.js +72 -14
  24. package/dist/indexer/search/name-match.js +35 -0
  25. package/dist/indexer/search/ranking-contributors.js +15 -12
  26. package/dist/indexer/search/ranking.js +42 -18
  27. package/dist/indexer/usage/show-usage.js +14 -2
  28. package/dist/llm/client.js +12 -8
  29. package/dist/llm/embedders/remote.js +3 -2
  30. package/dist/llm/graph-extract.js +18 -67
  31. package/dist/output/shapes.js +46 -1
  32. package/dist/output/text/proposal-format.js +5 -0
  33. package/dist/scripts/akm-migrate-node.js +648 -253
  34. package/dist/scripts/akm-migrate.js +648 -253
  35. package/dist/storage/repositories/index-connection.js +23 -8
  36. package/dist/storage/repositories/index-entries-repository.js +3 -2
  37. package/dist/storage/repositories/index-entry-schema.js +43 -3
  38. package/dist/storage/repositories/index-fts-repository.js +160 -14
  39. package/dist/storage/repositories/index-schema.js +8 -18
  40. package/dist/storage/repositories/workflow-runs-repository.js +118 -10
  41. package/dist/workflows/exec/run-workflow.js +1 -1
  42. package/dist/workflows/exec/step-work.js +41 -0
  43. package/dist/workflows/parser.js +1 -1
  44. package/dist/workflows/runtime/runs.js +29 -5
  45. package/docs/migration/release-notes/0.9.14.md +26 -0
  46. package/docs/migration/release-notes/README.md +2 -0
  47. package/docs/reference/cli.md +18 -0
  48. package/package.json +1 -1
@@ -0,0 +1,35 @@
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
+ import { buildLexicalQueryPlan } from "./fts-query.js";
5
+ /**
6
+ * Tokenize a display name through the same Unicode-aware lexical planner as a
7
+ * query. Ranking must not invent a second, punctuation-dependent name grammar.
8
+ */
9
+ export function lexicalNameTokens(name) {
10
+ return buildLexicalQueryPlan(name).tokens.map((token) => token.toLowerCase());
11
+ }
12
+ /**
13
+ * Structural name-token evidence. Exact tokens are always meaningful; fuzzy
14
+ * prefix evidence requires three code points on both sides so a question's
15
+ * short words or digits cannot match an opaque generated storage name.
16
+ */
17
+ export function structuralNameTokenMatch(left, right) {
18
+ return (left === right ||
19
+ (Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left))));
20
+ }
21
+ /**
22
+ * Match a query phrase only across complete, contiguous name tokens. This is
23
+ * deliberately not raw substring matching: `000` must not become name
24
+ * evidence merely because an opaque token happens to be `z9xq000`.
25
+ */
26
+ export function structuralNamePhraseMatch(nameTokens, queryTokens) {
27
+ if (queryTokens.length === 0 || queryTokens.length > nameTokens.length)
28
+ return false;
29
+ for (let start = 0; start <= nameTokens.length - queryTokens.length; start += 1) {
30
+ if (queryTokens.every((queryToken, index) => structuralNameTokenMatch(nameTokens[start + index], queryToken))) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { isKnownType } from "../../core/recognition-util.js";
5
5
  import { computeGraphBoost } from "../graph/graph-boost.js";
6
+ import { lexicalNameTokens, structuralNamePhraseMatch, structuralNameTokenMatch } from "./name-match.js";
6
7
  import { attachSearchHitAttribution } from "./search-attribution.js";
7
8
  /**
8
9
  * Chunk 1.5 (D1.5-5) — retyped from `Record<string, number>` to a FULL
@@ -105,13 +106,11 @@ function beliefStateBoost(item) {
105
106
  * stash-conventions-code-spec.md — corrections demotion).
106
107
  *
107
108
  * Why the additive {@link beliefStateBoost} penalties alone are not enough:
108
- * keyword base scores are min-max normalized into [0.3, 1.0]
109
- * (`normalizeFtsScores`), so the spread between the best FTS hit and its
110
- * runner-up can be as large as 0.7 and the boost sum then MULTIPLIES the
111
- * base (`score *= 1 + boostSum`, {@link applyScoreContributors}). A
112
- * superseded incumbent that is the best keyword match for a query therefore
113
- * stays clamp-pinned at 1.0 above its own correction no matter what additive
114
- * penalty it receives — defeating the corrections pattern's point ("so the
109
+ * keyword base scores have a bounded lexical floor (`normalizeFtsScores`),
110
+ * while the boost sum then MULTIPLIES the base (`score *= 1 + boostSum`,
111
+ * {@link applyScoreContributors}). A superseded incumbent can still earn
112
+ * enough independent boosts to outrank its own correction, so additive
113
+ * penalties alone cannot guarantee the corrections pattern's point ("so the
115
114
  * ranker demotes the stale version instead of letting it outrank your fix").
116
115
  *
117
116
  * The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
@@ -161,11 +160,10 @@ const exactNameRankingContributor = {
161
160
  if (nameBase === ctx.queryLower || nameLower === ctx.queryLower) {
162
161
  return 2.0;
163
162
  }
164
- if (nameBase.includes(ctx.queryLower) || ctx.queryLower.includes(nameBase)) {
163
+ const nameTokens = lexicalNameTokens(nameBase);
164
+ if (structuralNamePhraseMatch(nameTokens, ctx.queryTokens))
165
165
  return 1.0;
166
- }
167
- const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
168
- const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
166
+ const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => structuralNameTokenMatch(nt, qt))).length;
169
167
  return matchCount > 0 ? Math.min(0.9, matchCount * 0.3) : 0;
170
168
  },
171
169
  };
@@ -245,7 +243,12 @@ const aliasRankingContributor = {
245
243
  const descriptionRankingContributor = {
246
244
  name: "description-ranking",
247
245
  appliesTo(item) {
248
- return typeof item.entry.description === "string" && item.entry.description.length > 0;
246
+ // A relaxed FTS query admits an OR pool. Awarding a flat +0.1 merely
247
+ // because of a partial description coincidence double-counts a weak
248
+ // signal and can outrank materially stronger BM25 body evidence. The FTS
249
+ // score already accounts for descriptions; retain this secondary boost
250
+ // only for conjunctive candidates.
251
+ return (item.lexicalMatch !== "relaxed" && typeof item.entry.description === "string" && item.entry.description.length > 0);
249
252
  },
250
253
  adjust(item, ctx) {
251
254
  const descLower = item.entry.description?.toLowerCase() ?? "";
@@ -1,20 +1,36 @@
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 { stableFtsScore } from "../../core/lexical-score.js";
4
5
  import { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
5
6
  import { buildLexicalQueryPlan } from "./fts-query.js";
7
+ import { lexicalNameTokens, structuralNameTokenMatch } from "./name-match.js";
6
8
  import { applyBeliefStateScoreCeiling, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
9
+ /**
10
+ * Lower bounds keep a lexical hit competitive with a vector-only neighbour;
11
+ * the upper bound deliberately leaves room for the ranking contributors that
12
+ * run after retrieval (notably the bounded graph boost). This is a
13
+ * calibration for the one search pipeline, not a claim that BM25 is
14
+ * comparable across different queries or FTS tables.
15
+ */
16
+ /**
17
+ * Convert FTS5's negative BM25 value into the lexical contribution used by
18
+ * this pipeline. The transform is fixed and monotone: it depends only on a
19
+ * row's own BM25 value, so appending weaker candidates cannot rewrite an
20
+ * existing row's score. FTS5 commonly emits relevance near 1e-6 for broad
21
+ * queries, so first put relevance on a log scale around that observed value.
22
+ * The shape constant intentionally makes the curve approach its ceiling
23
+ * slowly: rare-term scores retain separation instead of all reading as 0.8.
24
+ *
25
+ * FTS5 produces finite non-positive values in normal operation. Keeping the
26
+ * defensive cases here finite makes this boundary safe if a driver or fixture
27
+ * hands us an invalid value: `-Infinity` is the strongest possible match,
28
+ * while NaN, +Infinity, and positive scores contribute no lexical evidence.
29
+ */
7
30
  export function normalizeFtsScores(results) {
8
31
  const ftsScoreMap = new Map();
9
- if (results.length === 0)
10
- return ftsScoreMap;
11
- const bestBm25 = results[0].bm25Score;
12
- const worstBm25 = results[results.length - 1].bm25Score;
13
- const range = bestBm25 - worstBm25;
14
32
  for (const result of results) {
15
- const normalized = range !== 0 ? (result.bm25Score - worstBm25) / range : 1.0;
16
- const ftsScore = 0.3 + normalized * 0.7;
17
- ftsScoreMap.set(result.id, { score: ftsScore, result });
33
+ ftsScoreMap.set(result.id, { score: result.lexicalScore ?? stableFtsScore(result.bm25Score), result });
18
34
  }
19
35
  return ftsScoreMap;
20
36
  }
@@ -38,6 +54,7 @@ export function combineSearchScores(options) {
38
54
  itemRef: result.itemRef,
39
55
  bundleId: result.bundleId,
40
56
  conceptId: result.conceptId,
57
+ fragmentId: result.fragmentId,
41
58
  });
42
59
  }
43
60
  for (const [id, cosine] of options.embedScoreMap) {
@@ -98,9 +115,8 @@ export function applyRankingRules(options) {
98
115
  applyRelaxedLexicalScoreCeiling(item, queryTokens);
99
116
  // SPEC-5: demoting belief states (superseded/contradicted/archived/
100
117
  // deprecated) cap the FINAL score. The additive belief penalty inside the
101
- // multiplicative boost sum cannot overcome the FTS min-max normalization
102
- // spread, so without the ceiling a superseded incumbent that is the best
103
- // keyword match outranks its own correction forever.
118
+ // multiplicative boost sum can still overwhelm an additive belief penalty,
119
+ // so without the ceiling a superseded incumbent can outrank its correction.
104
120
  applyBeliefStateScoreCeiling(item);
105
121
  }
106
122
  return options.items;
@@ -115,14 +131,12 @@ export function lexicalNameMatchTier(entry, queryTokens) {
115
131
  if (queryTokens.length === 0)
116
132
  return 0;
117
133
  const nameBase = entry.name.toLowerCase().split("/").pop() ?? entry.name.toLowerCase();
118
- const nameTokens = buildLexicalQueryPlan(nameBase).tokens.map((token) => token.toLowerCase());
119
- const tokenMatches = (left, right) => left === right ||
120
- (Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left)));
134
+ const nameTokens = lexicalNameTokens(nameBase);
121
135
  if (nameTokens.length === queryTokens.length &&
122
- nameTokens.every((token, index) => tokenMatches(token, queryTokens[index]))) {
136
+ nameTokens.every((token, index) => structuralNameTokenMatch(token, queryTokens[index]))) {
123
137
  return 3;
124
138
  }
125
- const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => tokenMatches(nameToken, token))).length;
139
+ const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => structuralNameTokenMatch(nameToken, token))).length;
126
140
  if (matched === queryTokens.length)
127
141
  return 2;
128
142
  return matched > 0 ? 1 : 0;
@@ -130,10 +144,20 @@ export function lexicalNameMatchTier(entry, queryTokens) {
130
144
  /**
131
145
  * A relaxed OR query admits intentionally weak candidates. Candidates with no
132
146
  * query token in their name remain visible for body-only recall, but cannot
133
- * saturate at the same displayed score as stronger name-bearing recoveries.
147
+ * share the same bounded displayed score as stronger name-bearing recoveries.
148
+ * The raw ceiling is 0.65; the public score projection is applied later, so
149
+ * callers never literally receive `0.65` just because this ceiling bound.
150
+ *
151
+ * Preserve the pre-ceiling relevance separately from `preCeilingScore`, which
152
+ * belongs to belief-state demotion and may be written afterwards. A relaxed,
153
+ * belief-demoted candidate otherwise loses both its body relevance and its
154
+ * ordering signal when the second ceiling overwrites the first.
134
155
  */
135
156
  function applyRelaxedLexicalScoreCeiling(item, queryTokens) {
136
157
  if (item.lexicalMatch !== "relaxed" || lexicalNameMatchTier(item.entry, queryTokens) > 0)
137
158
  return;
138
- item.score = Math.min(item.score, RELAXED_NON_NAME_SCORE_CEILING);
159
+ if (item.score > RELAXED_NON_NAME_SCORE_CEILING) {
160
+ item.preRelaxedCeilingScore = item.score;
161
+ item.score = RELAXED_NON_NAME_SCORE_CEILING;
162
+ }
139
163
  }
@@ -22,6 +22,18 @@ export function recentShowCount(ref) {
22
22
  return 0;
23
23
  }
24
24
  }
25
+ /** Compare search/display selectors by their durable parent asset identity. */
26
+ function traceParentRef(ref) {
27
+ try {
28
+ const parsed = parseBundleRef(ref);
29
+ return makeBundleRef(parsed.bundle, parsed.conceptId);
30
+ }
31
+ catch {
32
+ // Registry result refs are not local bundle refs and cannot have a local
33
+ // selector. Keep their existing exact comparison behavior.
34
+ return ref;
35
+ }
36
+ }
25
37
  function appendShowTrace(ref, type, name) {
26
38
  appendEvent({ eventType: "show", ref, metadata: { type, name } });
27
39
  try {
@@ -34,7 +46,7 @@ function appendShowTrace(ref, type, name) {
34
46
  if (!event.ts || new Date(event.ts).getTime() < cutoffMs)
35
47
  return false;
36
48
  const refs = event.metadata?.resultRefs ?? [];
37
- return refs.includes(ref);
49
+ return refs.some((candidate) => traceParentRef(candidate) === ref);
38
50
  });
39
51
  if (!matchingSearch)
40
52
  return;
@@ -45,7 +57,7 @@ function appendShowTrace(ref, type, name) {
45
57
  metadata: {
46
58
  query: matchingSearch.metadata?.query,
47
59
  searchTs: matchingSearch.ts,
48
- rankPosition: resultRefs.indexOf(ref),
60
+ rankPosition: resultRefs.findIndex((candidate) => traceParentRef(candidate) === ref),
49
61
  },
50
62
  });
51
63
  }
@@ -9,11 +9,12 @@
9
9
  */
10
10
  import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
11
11
  import { resolveSecret } from "../core/config/config.js";
12
- import { ENV_REFERENCE_PATTERN } from "../core/config/schema/primitives.js";
12
+ import { isApiKeyReference } from "../core/config/schema/primitives.js";
13
13
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
14
14
  import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
15
15
  import { warn, warnVerbose } from "../core/warn.js";
16
16
  import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
17
+ import { resolveSecretFromStore } from "../sources/snapshot-fetchers/secret-seam.js";
17
18
  import { emitLlmUsage, extractUsageTokens, } from "./usage-telemetry.js";
18
19
  /** Maximum length of an upstream response excerpt included in thrown errors. */
19
20
  const ERROR_BODY_MAX_LEN = 200;
@@ -264,13 +265,16 @@ async function chatCompletionAttemptOnce(config, messages, options, timeoutMs, i
264
265
  throw new Error(formatExtraParamsIssue("LLM extraParams", issue));
265
266
  }
266
267
  const headers = { "Content-Type": "application/json" };
267
- // Resolve ONLY a whole-string env reference. The execution boundary normally
268
- // hands us a materialized credential after resolving `$VAR` upstream, so
269
- // re-running the substitution over a literal key mangled any credential
270
- // containing `$` — `sk-live$ecret` lost everything from the `$` onward, and
271
- // the request failed with an opaque 401. The narrow check keeps the symbolic
272
- // form working for any direct caller that still passes one.
273
- const resolvedKey = ENV_REFERENCE_PATTERN.test(config.apiKey ?? "") ? resolveSecret(config.apiKey) : config.apiKey;
268
+ // Resolve ONLY a whole-string reference ($VAR/${VAR} or secret://<name>).
269
+ // The execution boundary normally hands us a materialized credential after
270
+ // resolving the reference upstream, so re-running the substitution over a
271
+ // literal key mangled any credential containing `$` — `sk-live$ecret` lost
272
+ // everything from the `$` onward, and the request failed with an opaque
273
+ // 401. The narrow check keeps the symbolic form working for any direct
274
+ // caller that still passes one.
275
+ const resolvedKey = isApiKeyReference(config.apiKey ?? "")
276
+ ? resolveSecret(config.apiKey, resolveSecretFromStore)
277
+ : config.apiKey;
274
278
  if (resolvedKey) {
275
279
  headers.Authorization = `Bearer ${resolvedKey}`;
276
280
  }
@@ -11,6 +11,7 @@ import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/com
11
11
  import { resolveSecret } from "../../core/config/config.js";
12
12
  import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
13
13
  import { warnVerbose } from "../../core/warn.js";
14
+ import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-seam.js";
14
15
  /**
15
16
  * Upper bound on the number of documents in one HTTP request, independent of
16
17
  * the token budget below. Overridable via `config.batchSize`. Purely a
@@ -215,7 +216,7 @@ export class RemoteEmbedder {
215
216
  }
216
217
  buildHeaders() {
217
218
  const headers = { "Content-Type": "application/json" };
218
- const resolvedKey = resolveSecret(this.config.apiKey);
219
+ const resolvedKey = resolveSecret(this.config.apiKey, resolveSecretFromStore);
219
220
  if (resolvedKey) {
220
221
  headers.Authorization = `Bearer ${resolvedKey}`;
221
222
  }
@@ -231,7 +232,7 @@ export class RemoteEmbedder {
231
232
  * far unredacted and uncapped, at readBodyWithByteCap's 10 MB default.
232
233
  */
233
234
  safeErrorBody(body) {
234
- const resolvedKey = resolveSecret(this.config.apiKey);
235
+ const resolvedKey = resolveSecret(this.config.apiKey, resolveSecretFromStore);
235
236
  return redactSensitiveText(redactErrorBody(body), resolvedKey ? [resolvedKey] : []);
236
237
  }
237
238
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import systemPromptTemplate from "../assets/prompts/graph-extract-system.md" with { type: "text" };
22
22
  import userPromptTemplate from "../assets/prompts/graph-extract-user-prompt.md" with { type: "text" };
23
+ import { splitMarkdownFragmentStats } from "../core/asset/markdown-fragments.js";
23
24
  import { toErrorMessage } from "../core/common.js";
24
25
  import { ConfigError } from "../core/errors.js";
25
26
  import { parseEmbeddedJsonResponse } from "../core/parse.js";
@@ -117,79 +118,29 @@ function normalizeBatchState(state) {
117
118
  state.nonArrayBatchFailures = Math.max(0, state.nonArrayBatchFailures ?? 0);
118
119
  return state;
119
120
  }
120
- function splitParagraph(text, maxChars) {
121
- if (text.length <= maxChars)
122
- return { chunks: [text], truncationCount: 0 };
123
- const chunks = [];
124
- let truncationCount = 0;
125
- let remaining = text;
126
- while (remaining.length > maxChars) {
127
- let splitAt = remaining.lastIndexOf(" ", maxChars);
128
- if (splitAt < Math.floor(maxChars * 0.6))
129
- splitAt = maxChars;
130
- const piece = remaining.slice(0, splitAt).trim();
131
- if (piece)
132
- chunks.push(piece);
133
- remaining = remaining.slice(splitAt).trim();
134
- truncationCount += 1;
135
- }
136
- if (remaining)
137
- chunks.push(remaining);
138
- return { chunks, truncationCount };
139
- }
140
121
  function splitBodyIntoChunks(body, maxChars = MAX_CHUNK_BODY_CHARS) {
141
- const sections = body
142
- .split(/\n(?=#{1,6}\s)/)
143
- .map((section) => section.trim())
144
- .filter(Boolean);
145
- if (sections.length === 0)
146
- return { chunks: [body.trim()].filter(Boolean), truncationCount: 0 };
122
+ const split = splitMarkdownFragmentStats(body, maxChars);
123
+ // Graph extraction keeps its historical cost shape: adjacent safe fragments
124
+ // share one LLM call whenever they fit. The fragment splitter is still the
125
+ // sole boundary authority; this is only prompt packing, never a second
126
+ // parser/chunker. Hard splits stay isolated and telemetry remains the core
127
+ // split count rather than counting ordinary heading boundaries.
147
128
  const chunks = [];
148
129
  let current = "";
149
- let truncationCount = 0;
150
- const flush = () => {
151
- const trimmed = current.trim();
152
- if (trimmed)
153
- chunks.push(trimmed);
154
- current = "";
155
- };
156
- for (const section of sections) {
157
- if (section.length <= maxChars) {
158
- const candidate = current ? `${current}\n\n${section}` : section;
159
- if (candidate.length <= maxChars)
160
- current = candidate;
161
- else {
162
- flush();
163
- current = section;
164
- }
165
- continue;
130
+ for (const fragment of split.fragments) {
131
+ const candidate = current ? `${current}\n\n${fragment.text}` : fragment.text;
132
+ if (candidate.length <= maxChars) {
133
+ current = candidate;
166
134
  }
167
- const paragraphs = section
168
- .split(/\n\s*\n/)
169
- .map((part) => part.trim())
170
- .filter(Boolean);
171
- for (const paragraph of paragraphs) {
172
- if (paragraph.length <= maxChars) {
173
- const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
174
- if (candidate.length <= maxChars)
175
- current = candidate;
176
- else {
177
- flush();
178
- current = paragraph;
179
- }
180
- continue;
181
- }
182
- flush();
183
- const split = splitParagraph(paragraph, maxChars);
184
- truncationCount += split.truncationCount;
185
- for (const piece of split.chunks) {
186
- if (piece.length <= maxChars)
187
- chunks.push(piece);
188
- }
135
+ else {
136
+ if (current)
137
+ chunks.push(current);
138
+ current = fragment.text;
189
139
  }
190
140
  }
191
- flush();
192
- return { chunks, truncationCount };
141
+ if (current)
142
+ chunks.push(current);
143
+ return { chunks, truncationCount: split.hardSplitCount };
193
144
  }
194
145
  /** Consistency weight for blending chunk-agreement with LLM confidence. */
195
146
  const CONSISTENCY_WEIGHT = 0.4;
@@ -66,6 +66,51 @@ registerOutputShapes(BUILT_IN_OUTPUT_SHAPES);
66
66
  * for a soon-frozen contract, not a silent fallback to `human`).
67
67
  */
68
68
  const SHAPE_SUMMARY_COMMANDS = new Set(["show"]);
69
+ // ── `results` collection alias ──────────────────────────────────────────────
70
+ //
71
+ // Every list-returning command names its collection differently (`hits`,
72
+ // `items`, `proposals`, `sources`, ...) — a caller cannot write one accessor
73
+ // across commands without a per-command lookup table, and the wrong guess
74
+ // (`d.get("hits")` against a `curate` response) reads as "no results" rather
75
+ // than "wrong key", with nothing in the envelope to correct it.
76
+ //
77
+ // This maps each list-returning command to the field already holding its
78
+ // collection, and `withResultsAlias` below adds a `results` key pointing at
79
+ // that SAME array (not a copy) to the shaped output — in every `--shape` /
80
+ // `--detail` combination, `human` included, so `--shape agent` needs no
81
+ // separate handling to "guarantee" it. A new list-returning command MUST add
82
+ // an entry here; there is no way to detect a missed one automatically, so the
83
+ // survey deliberately lives in this one place rather than scattered per
84
+ // handler.
85
+ const LIST_RESULT_COLLECTION_KEYS = {
86
+ search: "hits",
87
+ curate: "items",
88
+ "registry-search": "hits",
89
+ "proposal-list": "proposals",
90
+ list: "sources", // `akm bundle list`
91
+ "env-list": "envs",
92
+ "secret-list": "secrets",
93
+ "registry-list": "registries",
94
+ "workflow-list": "runs",
95
+ "task-history": "rows",
96
+ "log-list": "events", // `akm log list`
97
+ };
98
+ function withResultsAlias(command, shaped) {
99
+ const key = LIST_RESULT_COLLECTION_KEYS[command];
100
+ if (!key)
101
+ return shaped;
102
+ if (shaped === null || typeof shaped !== "object" || Array.isArray(shaped))
103
+ return shaped;
104
+ const obj = shaped;
105
+ if ("results" in obj)
106
+ return shaped;
107
+ const collection = obj[key];
108
+ if (!Array.isArray(collection))
109
+ return shaped;
110
+ // Same array reference as `obj[key]`, never a copy, so `results` cannot
111
+ // silently drift out of sync with the semantic key it aliases.
112
+ return { ...obj, results: collection };
113
+ }
69
114
  export function shapeForCommand(command, result, detail, shape = "human") {
70
115
  let effectiveShape = shape;
71
116
  if (shape === "summary" && !SHAPE_SUMMARY_COMMANDS.has(command)) {
@@ -74,7 +119,7 @@ export function shapeForCommand(command, result, detail, shape = "human") {
74
119
  }
75
120
  const handler = getOutputShapeHandler(command);
76
121
  if (handler) {
77
- return handler(result, detail, effectiveShape);
122
+ return withResultsAlias(command, handler(result, detail, effectiveShape));
78
123
  }
79
124
  // v1 spec §9 (output-shape registry exhaustive): no silent JSON.stringify
80
125
  // fallback. A missing case here is a registration bug — fail loudly so
@@ -191,6 +191,7 @@ export function formatProposalDrainPlain(r) {
191
191
  const deferred = Array.isArray(r.deferred) ? r.deferred : [];
192
192
  const skippedByCap = Array.isArray(r.skippedByCap) ? r.skippedByCap : [];
193
193
  const staged = Array.isArray(r.staged) ? r.staged : [];
194
+ const failed = Array.isArray(r.failed) ? r.failed : [];
194
195
  const prefix = r.dryRun === true ? "[dry-run] " : "";
195
196
  const lines = [
196
197
  `${prefix}Drained proposal queue (strategy=${String(r.strategy ?? "?")}, policy=${policy}, applyMode=${applyMode})`,
@@ -199,10 +200,14 @@ export function formatProposalDrainPlain(r) {
199
200
  ` deferred: ${deferred.length}`,
200
201
  ` skippedByCap: ${skippedByCap.length}`,
201
202
  ` staged: ${staged.length}`,
203
+ ` failed: ${failed.length}`,
202
204
  ];
203
205
  for (const d of deferred) {
204
206
  lines.push(` - ${String(d.id ?? "?")} (${String(d.reason ?? "?")})`);
205
207
  }
208
+ for (const f of failed) {
209
+ lines.push(` ! ${String(f.id ?? "?")} (${String(f.reason ?? "?")}): ${String(f.detail ?? "?")}`);
210
+ }
206
211
  appendLoweringNotices(lines, r);
207
212
  return lines.join("\n").trimEnd();
208
213
  }