ucn 5.4.2 → 5.5.0

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 (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
@@ -16,18 +16,19 @@ const BROAD_COMMANDS = new Set([
16
16
  ]);
17
17
 
18
18
  const CONTRACT_LINE_RE = /^\s*(?:(?:Summary|ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT|Note):|\d+ test-file usage\(s\) hidden\b|Found \d+ (?:definitions|fuzzy matches)\b)/;
19
+ // Trust lines: the accounting and contract lines that qualify a caller
20
+ // answer. A truncated answer carries every one of them or none of its body.
21
+ const TRUST_LINE_RE = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/;
19
22
  const MAX_PRESERVED_CONTRACT_LINES = 24;
20
23
  const MAX_PRESERVED_CONTRACT_CHARS = 8000;
21
24
 
22
- function preservedContractMetadata(fullText, visibleText, options = {}) {
23
- const visible = new Set(visibleText.split('\n').map(line => line.trim()));
25
+ /**
26
+ * Contract metadata candidates of a text, in source order: trust lines
27
+ * (priority 0), the hidden-test-scope sentence (1), and other disclosures
28
+ * (2: Summary, Note, definition counts).
29
+ */
30
+ function contractCandidates(fullText) {
24
31
  const candidates = [];
25
- const selected = [];
26
- let selectedChars = 0;
27
- let omitted = 0;
28
- const maxLines = options.maxLines ?? MAX_PRESERVED_CONTRACT_LINES;
29
- const maxChars = options.maxChars ?? MAX_PRESERVED_CONTRACT_CHARS;
30
-
31
32
  for (const [sourceIndex, rawLine] of fullText.split('\n').entries()) {
32
33
  // Execution notes may concatenate several independent disclosures on
33
34
  // one physical line. Preserve the actionable test-scope contract as
@@ -44,13 +45,23 @@ function preservedContractMetadata(fullText, visibleText, options = {}) {
44
45
  const evidenceLine = contractLine.replace(/^\s*# /, '').trim();
45
46
  if (!CONTRACT_LINE_RE.test(evidenceLine)) continue;
46
47
  const line = contractLine.trim();
47
- if (!line || visible.has(line)) continue;
48
- const priority = /^(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/.test(evidenceLine)
48
+ if (!line) continue;
49
+ const priority = TRUST_LINE_RE.test(evidenceLine)
49
50
  ? 0
50
51
  : /^\d+ test-file usage\(s\) hidden\b/.test(evidenceLine) ? 1 : 2;
51
52
  candidates.push({ line, priority, sourceIndex });
52
53
  }
54
+ return candidates;
55
+ }
53
56
 
57
+ function preservedContractMetadata(fullText, visibleText, options = {}) {
58
+ const visible = new Set(visibleText.split('\n').map(line => line.trim()));
59
+ const selected = [];
60
+ let selectedChars = 0;
61
+ let omitted = 0;
62
+ const maxLines = options.maxLines ?? MAX_PRESERVED_CONTRACT_LINES;
63
+ const maxChars = options.maxChars ?? MAX_PRESERVED_CONTRACT_CHARS;
64
+ const candidates = contractCandidates(fullText).filter(candidate => !visible.has(candidate.line));
54
65
  for (const { line } of candidates.sort((a, b) =>
55
66
  a.priority - b.priority || a.sourceIndex - b.sourceIndex)) {
56
67
  if (selected.length >= maxLines ||
@@ -76,6 +87,7 @@ function narrowingHint(command, surface, params = {}) {
76
87
  usages: 'Use --file or --in to narrow the result.',
77
88
  deps: 'Use --depth=1 or --direction=imports|importers.',
78
89
  api: 'Use --limit=N to narrow the result.',
90
+ find: 'Use --compact, --limit=N, --type, --file, --in, or --exclude to narrow the result.',
79
91
  };
80
92
  const mcp = {
81
93
  repo: 'Use sections=, in=, or exclude= to narrow the view.',
@@ -87,6 +99,7 @@ function narrowingHint(command, surface, params = {}) {
87
99
  usages: 'Use file= or in= to narrow the result.',
88
100
  deps: 'Use depth=1 or direction=imports|importers.',
89
101
  api: 'Use limit=<n> to narrow the result.',
102
+ find: 'Use compact=true, limit=<n>, type=, file=, in=, or exclude= to narrow the result.',
90
103
  };
91
104
  const canonical = String(command).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
92
105
  if (canonical === 'deps' && params.cycles) {
@@ -118,10 +131,113 @@ function compactNarrowingHint(command, surface, params = {}) {
118
131
  const flags = full.match(/--[a-z-]+(?:=N)?/g) || [];
119
132
  return [...new Set(flags)].join('/');
120
133
  }
121
- const flags = full.match(/\b(?:sections|in|exclude|framework|prefix|method|server_only|client_only|file|limit|depth|direction|max_chars)(?:=<n>|=true|=imports\|importers|=1|=)?/g) || [];
134
+ const flags = full.match(/\b(?:sections|in|exclude|framework|prefix|method|server_only|client_only|file|limit|depth|direction|max_chars|compact|type)(?:=<n>|=true|=imports\|importers|=1|=)?/g) || [];
122
135
  return [...new Set(flags)].join('/');
123
136
  }
124
137
 
138
+ // fix #367g: tier-section headers of the relationship answers
139
+ // (`CALLERS — CONFIRMED (12, ...)`, `CALLERS — RUNTIME DISPATCH (332 sites`,
140
+ // `UNVERIFIED CALL SITES (627) —`, `CALLEES (3):`, `BY FILE:`).
141
+ const SECTION_HEADER_RE = /^(?:[A-Z][A-Z-]*(?: [A-Z][A-Z-]*)*(?: — [A-Z][A-Z -]*)? \(\d[^)]*\)|[A-Z][A-Z ]+:$)/;
142
+ const SECTION_AWARE_COMMANDS = new Set(['show', 'impact']);
143
+
144
+ /**
145
+ * Cut a relationship answer to `budget` chars keeping a representative head
146
+ * of EVERY tier section instead of the first sections only: a long CONFIRMED
147
+ * list must not push the UNVERIFIED band (or the excluded summary) out of
148
+ * the answer. Section bodies are the indented lines under a header; lines
149
+ * are handed out round-robin across sections, each cut section ends with a
150
+ * `+N more` line, and the non-indented lines around sections (context,
151
+ * ACCOUNT/CONTRACT/WARNING) stay in place. Returns null when the text has
152
+ * no sections or its fixed parts alone do not fit (caller falls back to a
153
+ * head cut).
154
+ */
155
+ function sectionAwareCut(text, budget) {
156
+ const lines = text.split('\n');
157
+ const blocks = []; // { kind: 'fixed', line } | { kind: 'section', header, body }
158
+ let current = null;
159
+ let sawSection = false;
160
+ for (const line of lines) {
161
+ if (SECTION_HEADER_RE.test(line)) {
162
+ current = { kind: 'section', header: line, body: [] };
163
+ blocks.push(current);
164
+ sawSection = true;
165
+ continue;
166
+ }
167
+ if (current && /^\s/.test(line) && line.trim()) {
168
+ current.body.push(line);
169
+ continue;
170
+ }
171
+ current = null;
172
+ blocks.push({ kind: 'fixed', line });
173
+ }
174
+ const sections = blocks.filter(block => block.kind === 'section');
175
+ if (!sawSection || sections.every(section => section.body.length === 0)) return null;
176
+ const moreLine = count => ` ... +${count} more`;
177
+ const gapLine = ' ...';
178
+ let used = 0;
179
+ for (const block of blocks) {
180
+ used += (block.kind === 'fixed' ? block.line.length : block.header.length) + 1;
181
+ }
182
+ // Reserve the worst-case `+N more` line and one gap marker per section.
183
+ for (const section of sections) {
184
+ used += moreLine(section.body.length).length + gapLine.length + 2;
185
+ }
186
+ if (used > budget) return null;
187
+ // Selection order per section: the first numbered site entry and the
188
+ // sub-headers above it come first, so a long preamble list (competing
189
+ // definitions) cannot crowd every site out; then lines in source order.
190
+ const indentOf = line => line.length - line.trimStart().length;
191
+ const orders = new Map();
192
+ for (const section of sections) {
193
+ const first = section.body.findIndex(line => /^\s+\[\d+\]/.test(line));
194
+ const order = [];
195
+ if (first > 0) {
196
+ const chain = [];
197
+ let indent = indentOf(section.body[first]);
198
+ for (let i = first - 1; i >= 0 && indent > 0; i--) {
199
+ const lineIndent = indentOf(section.body[i]);
200
+ if (lineIndent < indent) { chain.unshift(i); indent = lineIndent; }
201
+ }
202
+ order.push(...chain, first);
203
+ }
204
+ const seen = new Set(order);
205
+ for (let i = 0; i < section.body.length; i++) if (!seen.has(i)) order.push(i);
206
+ orders.set(section, { order, next: 0, taken: new Set() });
207
+ }
208
+ let progress = true;
209
+ while (progress) {
210
+ progress = false;
211
+ for (const section of sections) {
212
+ const state = orders.get(section);
213
+ if (state.next >= state.order.length) continue;
214
+ const index = state.order[state.next];
215
+ const cost = section.body[index].length + 1;
216
+ if (used + cost > budget) { state.next = state.order.length; continue; }
217
+ used += cost;
218
+ state.taken.add(index);
219
+ state.next++;
220
+ progress = true;
221
+ }
222
+ }
223
+ const out = [];
224
+ for (const block of blocks) {
225
+ if (block.kind === 'fixed') { out.push(block.line); continue; }
226
+ out.push(block.header);
227
+ const { taken } = orders.get(block);
228
+ let last = -1;
229
+ for (let i = 0; i < block.body.length; i++) {
230
+ if (!taken.has(i)) continue;
231
+ if (i > last + 1 && last >= 0) out.push(gapLine);
232
+ out.push(block.body[i]);
233
+ last = i;
234
+ }
235
+ const omitted = block.body.length - taken.size;
236
+ if (omitted > 0) out.push(moreLine(omitted));
237
+ }
238
+ return out.join('\n');
239
+ }
240
+
125
241
  /**
126
242
  * Apply the same bounded-output contract to CLI and MCP text. JSON is not
127
243
  * passed here: structured consumers receive the complete stable envelope.
@@ -179,127 +295,193 @@ function applyOutputBudget(text, {
179
295
  const compactGuidance = compactScope.includes(raiseHint)
180
296
  ? `Raise ${raiseHint}.`
181
297
  : `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
182
- let notice = compactBudget
183
- ? `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`
184
- : `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
185
- `${narrowingHint(command, surface, params)} ${allHint}`;
186
- if (compactBudget && notice.length > limit) {
187
- const emergency = supportsAll
188
- ? (surface === 'mcp'
189
- ? 'all=true/max_chars=<n>'
190
- : '--all/--max-chars=N')
191
- : raiseHint;
192
- notice = emergency.slice(0, limit);
193
- }
298
+ const longNotice = `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
299
+ `${narrowingHint(command, surface, params)} ${allHint}`;
300
+ const compactNotice = `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`;
301
+ const shortNotice = `... OUTPUT TRUNCATED. Raise ${raiseHint}.`;
194
302
 
195
- // At very small explicit budgets, the verbose preservation heading alone
196
- // can consume most of the transport. Trust/account lines take precedence
197
- // over body detail and are appended directly after a compact notice.
198
- if (compactBudget) {
199
- let candidate = preservedContractMetadata(text, '', {
200
- maxChars: Math.max(0, limit - notice.length - 1),
201
- });
202
- if (candidate.omitted > 0) {
203
- // Spend less on generic guidance when that lets a complete scope
204
- // or parameter warning survive even a tiny transport ceiling.
205
- const shorterNotice = `... OUTPUT TRUNCATED. Raise ${raiseHint}.`;
206
- const shorterCandidate = preservedContractMetadata(text, '', {
207
- maxChars: Math.max(0, limit - shorterNotice.length - 1),
208
- });
209
- if (shorterNotice.length < notice.length && shorterCandidate.lines.length > candidate.lines.length) {
210
- notice = shorterNotice;
211
- candidate = shorterCandidate;
303
+ // Trust lines (ACCOUNT/CONTRACT/WARNING/...) qualify the answer: room for
304
+ // every one of them is reserved before any body text. Optional
305
+ // disclosures (hidden test scope, notes, summaries) fill what is left.
306
+ const candidates = contractCandidates(text);
307
+ const trust = candidates.filter(candidate => candidate.priority === 0);
308
+ const optional = candidates.filter(candidate => candidate.priority > 0)
309
+ .sort((a, b) => a.priority - b.priority || a.sourceIndex - b.sourceIndex);
310
+ const trustChars = trust.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
311
+ const result = (rendered, appended, complete) => ({
312
+ text: rendered,
313
+ truncated: true,
314
+ fullChars: text.length,
315
+ requestedLimit: hardLimit,
316
+ contractMetadata: appended,
317
+ contractMetadataComplete: complete,
318
+ });
319
+
320
+ // Head cut of the body to `budget` chars; returns the kept text and the
321
+ // number of complete source lines it holds.
322
+ const headCut = (budget) => {
323
+ if (budget <= 0) return { body: '', keptLines: 0 };
324
+ const prefix = text.slice(0, budget);
325
+ const lastNewline = prefix.lastIndexOf('\n');
326
+ const body = lastNewline > budget * 0.8 ? prefix.slice(0, lastNewline) : prefix;
327
+ const newlines = body.split('\n').length - 1;
328
+ // The last line of the kept body is complete only when the cut fell
329
+ // on a line boundary.
330
+ const lastComplete = body.length === text.length || text[body.length] === '\n';
331
+ return { body, keptLines: newlines + (body && lastComplete ? 1 : 0) };
332
+ };
333
+
334
+ // Render one layout: body, notice, then the metadata the body does not
335
+ // show. Every trust line is reserved first; optional disclosures get up
336
+ // to `optionalShare` chars (priority order) before the body; the body
337
+ // takes the rest. Returns null when the notice and the trust lines alone
338
+ // exceed the limit.
339
+ const costOf = list => list.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
340
+ const layout = (notice, heading, separator, optionalShare) => {
341
+ const reserveFor = metadataChars => notice.length + (metadataChars > 0 ? heading.length + metadataChars : 0);
342
+ if (reserveFor(trustChars) > limit) return null;
343
+ const reservedOptional = [];
344
+ let optionalChars = 0;
345
+ const optionalRoom = Math.min(optionalShare, limit - reserveFor(trustChars) - (trustChars > 0 ? 0 : heading.length));
346
+ for (const candidate of optional) {
347
+ if (reservedOptional.length >= MAX_PRESERVED_CONTRACT_LINES) break;
348
+ if (optionalChars + candidate.line.length + 1 > optionalRoom) continue;
349
+ reservedOptional.push(candidate);
350
+ optionalChars += candidate.line.length + 1;
351
+ }
352
+ const reserved = [...trust, ...reservedOptional];
353
+ const pendingOf = cutState => reserved.filter(candidate => candidate.sourceIndex >= cutState.keptLines);
354
+ let cut = headCut(limit - reserveFor(costOf(reserved)) - separator.length);
355
+ // Reserved lines the kept body already shows need no room; spend it
356
+ // on the body (one refinement pass; a wider cut only shows more).
357
+ const firstPending = costOf(pendingOf(cut));
358
+ if (firstPending < costOf(reserved)) {
359
+ const wider = headCut(limit - reserveFor(firstPending) - separator.length);
360
+ if (costOf(pendingOf(wider)) <= firstPending) cut = wider;
361
+ }
362
+ const metadata = pendingOf(cut);
363
+ let rendered = cut.body ? `${cut.body}${separator}${notice}` : notice;
364
+ let used = rendered.length + (metadata.length > 0 ? heading.length + costOf(metadata) : 0);
365
+ let omitted = 0;
366
+ const taken = new Set(reserved);
367
+ for (const candidate of optional) {
368
+ if (taken.has(candidate) || candidate.sourceIndex < cut.keptLines) continue;
369
+ const cost = candidate.line.length + 1 + (metadata.length === 0 ? heading.length : 0);
370
+ if (metadata.length >= trust.length + MAX_PRESERVED_CONTRACT_LINES || used + cost > limit) {
371
+ omitted++;
372
+ continue;
212
373
  }
374
+ metadata.push(candidate);
375
+ used += cost;
213
376
  }
214
- const candidateText = candidate.lines.join('\n');
215
- const separatorChars = candidateText ? 2 : 1;
216
- const bodyBudget = Math.max(0,
217
- limit - notice.length - candidateText.length - separatorChars);
218
- const prefix = text.slice(0, bodyBudget);
219
- const lastNewline = prefix.lastIndexOf('\n');
220
- const cleanCut = lastNewline > bodyBudget * 0.8
221
- ? prefix.slice(0, lastNewline)
222
- : prefix;
223
- const remainingForMetadata = Math.max(0,
224
- limit - cleanCut.length - notice.length -
225
- (cleanCut ? 1 : 0) - 1);
226
- const contractMetadata = preservedContractMetadata(text, cleanCut, {
227
- // preservedContractMetadata accounts a trailing newline per item;
228
- // the renderer joins the final item without one.
229
- maxChars: remainingForMetadata + 1,
230
- });
231
- const pieces = [];
232
- if (cleanCut) pieces.push(cleanCut);
233
- pieces.push(notice);
234
- if (contractMetadata.lines.length > 0) {
235
- pieces.push(contractMetadata.lines.join('\n'));
377
+ const appended = [];
378
+ if (metadata.length > 0) {
379
+ // Trust lines and optional disclosures keep their source order.
380
+ metadata.sort((x, y) => x.sourceIndex - y.sourceIndex);
381
+ rendered += heading;
382
+ for (const candidate of metadata) {
383
+ rendered += '\n' + candidate.line;
384
+ appended.push(candidate.line);
385
+ }
236
386
  }
237
- return {
238
- text: pieces.join('\n').slice(0, limit),
239
- truncated: true,
240
- fullChars: text.length,
241
- requestedLimit: hardLimit,
242
- contractMetadata: contractMetadata.lines,
243
- contractMetadataComplete: contractMetadata.complete,
244
- };
245
- }
387
+ return result(rendered, appended, omitted === 0);
388
+ };
246
389
 
247
- // max_chars is a transport ceiling, not merely a body target. Reserve a
248
- // bounded share for omitted trust/account lines, then spend the remainder
249
- // on the original body and truncation notice. Tiny limits may be too small
250
- // for any metadata; the returned completeness bit records that fact.
251
- const metadataBudget = Math.min(
252
- MAX_PRESERVED_CONTRACT_CHARS,
253
- Math.max(0, Math.floor(limit * 0.50)),
254
- );
255
- const candidateMetadata = preservedContractMetadata(text, '', {
256
- maxChars: metadataBudget,
257
- });
258
- const provisionalMetadata = candidateMetadata.lines.length > 0
259
- ? '\n\nPRESERVED CONTRACT METADATA (from omitted output):\n' +
260
- candidateMetadata.lines.join('\n')
261
- : '';
262
- const bodyBudget = Math.max(0,
263
- limit - notice.length - provisionalMetadata.length - 2);
264
- const truncated = text.substring(0, bodyBudget);
265
- const lastNewline = truncated.lastIndexOf('\n');
266
- const cleanCut = lastNewline > bodyBudget * 0.8
267
- ? truncated.substring(0, lastNewline)
268
- : truncated;
269
- const metadataHeading = '\n\nPRESERVED CONTRACT METADATA (from omitted output):';
270
- const remainingForMetadata = Math.max(0,
271
- limit - cleanCut.length - notice.length - (cleanCut ? 2 : 0) -
272
- metadataHeading.length);
273
- const contractMetadata = preservedContractMetadata(text, cleanCut, {
274
- maxChars: Math.min(metadataBudget, remainingForMetadata),
275
- });
276
- let rendered = `${cleanCut}${cleanCut ? '\n\n' : ''}${notice}`;
390
+ // The notice and the trust lines alone do not fit: the answer is
391
+ // withheld rather than shown without its contract, and the notice says
392
+ // so with the budget that would carry them.
393
+ const withheld = () => {
394
+ const flag = surface === 'mcp' ? 'max_chars' : '--max-chars';
395
+ const noticeFor = (shown, needed) => `... OUTPUT TRUNCATED (${text.length}→${hardLimit}): answer withheld; ` +
396
+ `${shown} of ${trust.length} trust line(s) shown. Raise ${flag} to at least ${needed}.`;
397
+ // Smallest budget whose shortest layout carries every trust line.
398
+ const needed = trailingChars + shortNotice.length + trustChars;
399
+ let shown = 0;
400
+ let used = noticeFor(trust.length, needed).length;
401
+ const kept = [];
402
+ for (const candidate of trust) {
403
+ if (used + candidate.line.length + 1 > limit) break;
404
+ kept.push(candidate.line);
405
+ used += candidate.line.length + 1;
406
+ shown++;
407
+ }
408
+ const notice = noticeFor(shown, needed);
409
+ if (notice.length > limit) {
410
+ // Shorter statements of the same fact; a budget number is never
411
+ // cut mid-digit.
412
+ const shorter = [
413
+ `TRUNCATED: answer withheld; raise ${flag} to at least ${needed}.`,
414
+ `Answer withheld; ${flag}>=${needed}`,
415
+ `withheld; >=${needed}`,
416
+ ].find(text => text.length <= limit);
417
+ return result(shorter || '', [], false);
418
+ }
419
+ return result([notice, ...kept].join('\n'), kept, false);
420
+ };
277
421
 
278
- if (contractMetadata.lines.length > 0) {
279
- const heading = metadataHeading;
280
- if (rendered.length + heading.length <= limit) rendered += heading;
281
- for (const line of contractMetadata.lines) {
282
- if (rendered.length + line.length + 1 > limit) break;
283
- rendered += '\n' + line;
422
+ // At tiny limits with no trust line to carry, the notice itself is the
423
+ // answer: the flag that raises the budget.
424
+ const bare = () => result((supportsAll
425
+ ? (surface === 'mcp' ? 'all=true/max_chars=<n>' : '--all/--max-chars=N')
426
+ : raiseHint).slice(0, limit), [], optional.length === 0);
427
+
428
+ // First layout that carries every disclosure, else the one carrying the
429
+ // most (a shorter notice can leave room for a scope warning).
430
+ const pick = (...attempts) => {
431
+ let best = null;
432
+ for (const attempt of attempts) {
433
+ const rendered = attempt();
434
+ if (!rendered) continue;
435
+ if (rendered.contractMetadataComplete) return rendered;
436
+ if (!best || rendered.contractMetadata.length > best.contractMetadata.length) best = rendered;
284
437
  }
285
- }
286
- if (contractMetadata.omitted > 0) {
287
- const warning = `\nWARNING: ${contractMetadata.omitted} additional contract line(s) ` +
288
- 'could not fit the preservation budget; narrow scope before acting.';
289
- if (rendered.length + warning.length <= limit) rendered += warning;
438
+ return best;
439
+ };
440
+
441
+ if (compactBudget) {
442
+ // Tiny transports: every disclosure outranks body detail.
443
+ return pick(() => layout(compactNotice, '', '\n', limit), () => layout(shortNotice, '', '\n', limit)) ||
444
+ (trust.length > 0 ? withheld() : bare());
290
445
  }
291
446
 
292
- // Defensive final ceiling for very small limits and future notice edits.
293
- rendered = rendered.slice(0, limit);
447
+ if (SECTION_AWARE_COMMANDS.has(canonical)) {
448
+ // Sections keep every non-indented line (trust lines included) in
449
+ // place; trust lines inside a cut section body are appended, with
450
+ // their room taken from the sections.
451
+ let sectionBudget = limit - longNotice.length - 2;
452
+ for (let attempt = 0; attempt < 2; attempt++) {
453
+ const sectioned = sectionAwareCut(text, sectionBudget);
454
+ if (sectioned === null) break;
455
+ const visible = new Set(sectioned.split('\n').map(line => line.trim()));
456
+ const missing = trust.filter(candidate => !visible.has(candidate.line));
457
+ const missingChars = missing.reduce((sum, candidate) => sum + candidate.line.length + 1, 0);
458
+ let rendered = `${sectioned}\n\n${longNotice}`;
459
+ if (rendered.length + missingChars > limit) {
460
+ sectionBudget -= missingChars;
461
+ continue;
462
+ }
463
+ const appended = missing.map(candidate => candidate.line);
464
+ let omitted = 0;
465
+ let used = rendered.length + missingChars;
466
+ for (const candidate of optional) {
467
+ if (visible.has(candidate.line)) continue;
468
+ if (used + candidate.line.length + 1 > limit) { omitted++; continue; }
469
+ appended.push(candidate.line);
470
+ used += candidate.line.length + 1;
471
+ }
472
+ if (appended.length > 0) rendered += '\n' + appended.join('\n');
473
+ return result(rendered, appended, omitted === 0);
474
+ }
475
+ }
294
476
 
295
- return {
296
- text: rendered,
297
- truncated: true,
298
- fullChars: text.length,
299
- requestedLimit: hardLimit,
300
- contractMetadata: contractMetadata.lines,
301
- contractMetadataComplete: contractMetadata.complete,
302
- };
477
+ const heading = '\n\nPRESERVED CONTRACT METADATA (from omitted output):';
478
+ // Optional disclosures share at most half of the transport with the
479
+ // trust lines, as the preservation budget always did.
480
+ const optionalShare = Math.max(0, Math.min(MAX_PRESERVED_CONTRACT_CHARS, Math.floor(limit * 0.5)) - trustChars);
481
+ return pick(() => layout(longNotice, heading, '\n\n', optionalShare),
482
+ () => layout(compactNotice, '', '\n', optionalShare),
483
+ () => layout(shortNotice, '', '\n', optionalShare)) ||
484
+ (trust.length > 0 ? withheld() : bare());
303
485
  }
304
486
 
305
487
  module.exports = {
@@ -308,4 +490,5 @@ module.exports = {
308
490
  MAX_OUTPUT_CHARS,
309
491
  applyOutputBudget,
310
492
  preservedContractMetadata,
493
+ sectionAwareCut,
311
494
  };