@planu/cli 4.10.5 → 4.10.6

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 (31) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/engine/compact/tool-list-compactor.d.ts +1 -1
  3. package/dist/engine/evidence-gates/lifecycle-gate.js +17 -1
  4. package/dist/engine/evidence-index/done-drift.js +4 -1
  5. package/dist/engine/local-first/tool-classification.d.ts +6 -0
  6. package/dist/engine/local-first/tool-classification.js +119 -0
  7. package/dist/engine/qa-gate.js +13 -1
  8. package/dist/engine/scope-boundaries/scope-validator.js +14 -2
  9. package/dist/engine/validator/validation-report-writer.js +3 -4
  10. package/dist/tools/list-specs.js +15 -59
  11. package/dist/tools/output-compressor.d.ts +14 -1
  12. package/dist/tools/output-compressor.js +121 -2
  13. package/dist/tools/package-handoff.js +136 -12
  14. package/dist/tools/status-handler.js +6 -1
  15. package/dist/tools/token-recording.d.ts +2 -1
  16. package/dist/tools/token-recording.js +21 -2
  17. package/dist/tools/update-status/dod-gates.d.ts +2 -2
  18. package/dist/tools/update-status/dod-gates.js +8 -28
  19. package/dist/tools/update-status/index.js +7 -1
  20. package/dist/tools/update-status/qa-gate.d.ts +5 -0
  21. package/dist/tools/update-status/qa-gate.js +50 -0
  22. package/dist/tools/update-status/response-builder.js +96 -2
  23. package/dist/tools/update-status-convention-gate.js +22 -6
  24. package/dist/tools/validate.js +137 -16
  25. package/dist/transports/transport-factory.js +13 -0
  26. package/dist/types/qa-gate.d.ts +3 -0
  27. package/dist/types/tool-classification.d.ts +8 -0
  28. package/dist/types/tool-classification.js +2 -0
  29. package/package.json +1 -1
  30. package/planu-native.json +8 -29
  31. package/planu-plugin.json +7 -35
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [4.10.6] - 2026-07-07
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1111): stabilize update_status done gates and evidence state transitions
5
+ - fix(SPEC-1112): preserve validation-report lint evidence during done gate
6
+
7
+ ### Improvements
8
+ - refactor(SPEC-1110): reduce MCP token payloads with local-first tool classification
9
+
10
+
1
11
  ## [4.10.5] - 2026-07-07
2
12
 
3
13
  ### Bug Fixes
@@ -4189,4 +4199,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
4189
4199
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
4190
4200
  - Multi-language i18n (EN/ES/PT) for generated specs
4191
4201
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
4192
- - 10,857 tests with ≥95% coverage
4202
+ - 10,857 tests with ≥95% coverage
@@ -3,5 +3,5 @@ export declare function compactToolList(tools: unknown[]): Record<string, unknow
3
3
  /** Returns true when an outgoing JSON-RPC message is a tools/list result. */
4
4
  export declare function isToolsListResponse(message: unknown): boolean;
5
5
  /** Compact a tools/list JSON-RPC response message. Returns a new object. */
6
- export declare function compactToolsListMessage(message: Record<string, unknown>): Record<string, unknown>;
6
+ export declare function compactToolsListMessage<T extends Record<string, unknown>>(message: T): T;
7
7
  //# sourceMappingURL=tool-list-compactor.d.ts.map
@@ -114,9 +114,25 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
114
114
  !hasText(row.validationEvidence) ||
115
115
  !hasText(row.reviewerEvidence));
116
116
  if (incompleteRows.length > 0) {
117
+ const rowDetails = incompleteRows.map((row) => {
118
+ const missing = [];
119
+ if (!rowHasEvidence(row)) {
120
+ missing.push('scenario/testEvidence/contractEvidence/manualEvidence');
121
+ }
122
+ if (row.changedFiles.length === 0) {
123
+ missing.push('changedFiles');
124
+ }
125
+ if (!hasText(row.validationEvidence)) {
126
+ missing.push('validationEvidence');
127
+ }
128
+ if (!hasText(row.reviewerEvidence)) {
129
+ missing.push('reviewerEvidence');
130
+ }
131
+ return `${row.acceptanceCriterion}: missing ${missing.join(', ')}`;
132
+ });
117
133
  issues.push({
118
134
  code: 'traceability_incomplete_rows',
119
- message: 'Traceability rows must include scenario/test/contract/manual evidence, changed files, validation evidence, and reviewer evidence.',
135
+ message: `Traceability rows must include evidence, changed files, validation evidence, and reviewer evidence. Incomplete rows: ${rowDetails.join('; ')}`,
120
136
  });
121
137
  }
122
138
  }
@@ -33,9 +33,12 @@ export function checkDoneDriftContract(args) {
33
33
  .map((record) => `${entry.criterion}: ${record.value}`);
34
34
  });
35
35
  if (unapproved.length > 0) {
36
+ const groundedHint = args.groundedTechnicalPaths.length > 0
37
+ ? ` Grounded technical paths: ${args.groundedTechnicalPaths.join(', ')}.`
38
+ : ' No grounded technical paths were found in the approved spec.';
36
39
  issues.push({
37
40
  code: 'done_drift_unapproved_scope',
38
- message: `Done drift check found changed files outside grounded spec scope: ${unapproved.join('; ')}`,
41
+ message: `Done drift check found changed files outside grounded spec scope: ${unapproved.join('; ')}.${groundedHint} Add the file to approved technical references or include reviewerEvidence with an approved scope amendment.`,
39
42
  });
40
43
  }
41
44
  }
@@ -0,0 +1,6 @@
1
+ import type { ToolClassification } from '../../types/tool-classification.js';
2
+ export declare function getToolClassification(toolName: string): ToolClassification;
3
+ export declare function isLocalFirstTool(toolName: string): boolean;
4
+ export declare function requiresLlmByDefault(toolName: string): boolean;
5
+ export declare function listToolClassifications(): ToolClassification[];
6
+ //# sourceMappingURL=tool-classification.d.ts.map
@@ -0,0 +1,119 @@
1
+ const TOOL_CLASSIFICATIONS = new Map([
2
+ [
3
+ 'create_spec',
4
+ {
5
+ toolName: 'create_spec',
6
+ executionClass: 'llm-required',
7
+ tokenPolicy: 'llm-product',
8
+ reason: 'Generates the primary spec artifact and needs language synthesis.',
9
+ },
10
+ ],
11
+ [
12
+ 'validate',
13
+ {
14
+ toolName: 'validate',
15
+ executionClass: 'local',
16
+ tokenPolicy: 'summarize-first',
17
+ reason: 'Compares repository state and spec artifacts with deterministic validators.',
18
+ },
19
+ ],
20
+ [
21
+ 'audit',
22
+ {
23
+ toolName: 'audit',
24
+ executionClass: 'local',
25
+ tokenPolicy: 'summarize-first',
26
+ reason: 'Runs deterministic checks and returns issue summaries.',
27
+ },
28
+ ],
29
+ [
30
+ 'package_handoff',
31
+ {
32
+ toolName: 'package_handoff',
33
+ executionClass: 'local',
34
+ tokenPolicy: 'summarize-first',
35
+ reason: 'Packages existing spec and project context into an artifact reference.',
36
+ },
37
+ ],
38
+ [
39
+ 'list_specs',
40
+ {
41
+ toolName: 'list_specs',
42
+ executionClass: 'local',
43
+ tokenPolicy: 'summarize-first',
44
+ reason: 'Lists indexed spec metadata and must stay read-only.',
45
+ },
46
+ ],
47
+ [
48
+ 'summarize_spec',
49
+ {
50
+ toolName: 'summarize_spec',
51
+ executionClass: 'local',
52
+ tokenPolicy: 'summarize-first',
53
+ reason: 'Extracts bounded decision fields from an existing spec.',
54
+ },
55
+ ],
56
+ [
57
+ 'generate_tests',
58
+ {
59
+ toolName: 'generate_tests',
60
+ executionClass: 'hybrid',
61
+ tokenPolicy: 'summarize-first',
62
+ reason: 'Can generate deterministic scaffolds locally; novel test design may need LLM input.',
63
+ },
64
+ ],
65
+ [
66
+ 'recommend_model',
67
+ {
68
+ toolName: 'recommend_model',
69
+ executionClass: 'local',
70
+ tokenPolicy: 'no-llm',
71
+ reason: 'Uses local heuristics and project metadata for routing advice.',
72
+ },
73
+ ],
74
+ [
75
+ 'multi_agent_review',
76
+ {
77
+ toolName: 'multi_agent_review',
78
+ executionClass: 'hybrid',
79
+ tokenPolicy: 'summarize-first',
80
+ reason: 'Local orchestration and evidence collection precede any LLM review synthesis.',
81
+ },
82
+ ],
83
+ [
84
+ 'audit_specs_drift',
85
+ {
86
+ toolName: 'audit_specs_drift',
87
+ executionClass: 'local',
88
+ tokenPolicy: 'summarize-first',
89
+ reason: 'Diffs spec and implementation artifacts locally.',
90
+ },
91
+ ],
92
+ [
93
+ 'orchestrate_runtime',
94
+ {
95
+ toolName: 'orchestrate_runtime',
96
+ executionClass: 'local',
97
+ tokenPolicy: 'no-llm',
98
+ reason: 'Selects runtime behavior from local state and explicit policy.',
99
+ },
100
+ ],
101
+ ]);
102
+ export function getToolClassification(toolName) {
103
+ return (TOOL_CLASSIFICATIONS.get(toolName) ?? {
104
+ toolName,
105
+ executionClass: 'hybrid',
106
+ tokenPolicy: 'summarize-first',
107
+ reason: 'Unregistered tools default to local preprocessing with bounded summaries.',
108
+ });
109
+ }
110
+ export function isLocalFirstTool(toolName) {
111
+ return getToolClassification(toolName).executionClass !== 'llm-required';
112
+ }
113
+ export function requiresLlmByDefault(toolName) {
114
+ return getToolClassification(toolName).executionClass === 'llm-required';
115
+ }
116
+ export function listToolClassifications() {
117
+ return [...TOOL_CLASSIFICATIONS.values()].sort((a, b) => a.toolName.localeCompare(b.toolName));
118
+ }
119
+ //# sourceMappingURL=tool-classification.js.map
@@ -20,7 +20,19 @@ function runCheck(name, command, args, cwd) {
20
20
  const durationMs = Date.now() - start;
21
21
  const passed = result.status === 0 && !result.error;
22
22
  const output = [result.stdout, result.stderr].join('\n').trim().slice(0, 2000);
23
- return { name, passed, output, durationMs };
23
+ const errorCode = result.error && 'code' in result.error
24
+ ? String(result.error.code)
25
+ : undefined;
26
+ const timedOut = errorCode === 'ETIMEDOUT' || result.signal === 'SIGTERM';
27
+ return {
28
+ name,
29
+ passed,
30
+ output,
31
+ durationMs,
32
+ command: [command, ...args].join(' '),
33
+ timedOut,
34
+ errorMessage: result.error?.message,
35
+ };
24
36
  }
25
37
  export function runQaGate(spec, projectPath) {
26
38
  const coverageThreshold = extractCoverageThreshold(spec);
@@ -11,11 +11,23 @@ function normalize(text) {
11
11
  /**
12
12
  * Extract keywords from an out-of-scope item phrase (>3 chars).
13
13
  */
14
- const STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'will']);
14
+ const STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'will', 'every']);
15
+ const GENERIC_SCOPE_TOKENS = new Set([
16
+ 'code',
17
+ 'file',
18
+ 'files',
19
+ 'module',
20
+ 'modules',
21
+ 'mcp',
22
+ 'response',
23
+ 'responses',
24
+ 'tool',
25
+ 'tools',
26
+ ]);
15
27
  function keywords(phrase) {
16
28
  return normalize(phrase)
17
29
  .split(' ')
18
- .filter((w) => w.length > 3 && !STOP_WORDS.has(w));
30
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w) && !GENERIC_SCOPE_TOKENS.has(w));
19
31
  }
20
32
  /**
21
33
  * Determine whether a file path matches an out-of-scope item.
@@ -9,7 +9,7 @@ export async function writeImplementationReviewReport(input) {
9
9
  specCompliance,
10
10
  minimalityReport: input.minimalityReport,
11
11
  });
12
- const mergedGates = await preserveUnobservedBlockingGates(input, gates);
12
+ const mergedGates = await preserveUnobservedGates(input, gates);
13
13
  const passed = mergedGates.every((gate) => gate.passed);
14
14
  const reviewer = {
15
15
  kind: 'implementation-review-agent',
@@ -56,7 +56,7 @@ export async function writeImplementationReviewReport(input) {
56
56
  };
57
57
  }
58
58
  }
59
- async function preserveUnobservedBlockingGates(input, nextGates) {
59
+ async function preserveUnobservedGates(input, nextGates) {
60
60
  const currentGateNames = new Set(nextGates.map((gate) => gate.name));
61
61
  const explicitlyObserved = new Set([
62
62
  ...(input.lintPassed !== undefined ? ['lint'] : []),
@@ -71,8 +71,7 @@ async function preserveUnobservedBlockingGates(input, nextGates) {
71
71
  if (!existing?.ok) {
72
72
  return nextGates;
73
73
  }
74
- const preserved = existing.payload.gates.filter((gate) => !gate.passed &&
75
- currentGateNames.has(gate.name) &&
74
+ const preserved = existing.payload.gates.filter((gate) => currentGateNames.has(gate.name) &&
76
75
  !explicitlyObserved.has(gate.name) &&
77
76
  (gate.name === 'lint' ||
78
77
  gate.name === 'convention-regression' ||
@@ -4,16 +4,15 @@ import { buildListSpecsSummary } from '../engine/human-summary.js';
4
4
  import { dirname } from 'node:path';
5
5
  import { checkBundledVersionGap } from '../engine/version-detector/bundled-version-checker.js';
6
6
  import { readLastModifiedTimestamp, formatRelativeDate } from '../engine/spec-changelog/index.js';
7
- import { discoverAndFlattenSpecs, filterUnprefixedSpecs, importFilesystemSpecs, migrateSpecFolderNames, reconcileSpecPaths, scanForAmbiguousCriteria, } from '../engine/spec-migrator.js';
7
+ import { scanForAmbiguousCriteria } from '../engine/spec-migrator.js';
8
8
  import { detectDrift, formatDriftMessage } from '../engine/spec-migrator/drift-detector.js';
9
9
  import { AutopilotSummaryCollector } from '../engine/autopilot/summary-collector.js';
10
- import { withAudit } from '../engine/autopilot/audit-logger.js';
11
10
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
12
- import { refreshProjectStatus } from '../engine/autopilot/state-updater.js';
13
11
  import { resolveProjectIdOrAutoDetect } from './resolve-project-id.js';
14
12
  import { isNativeActive, fastScanSpecs } from '../engine/core-bridge.js';
15
13
  /** Track which projects have been auto-discovered this session (once per project). */
16
14
  const discoveredProjects = new Set();
15
+ const MAX_STRUCTURED_SPECS = 50;
17
16
  /** Build markdown table rows for summary mode */
18
17
  function buildSummaryTable(specs) {
19
18
  const header = '| ID | Title | Status | Type | Tags |';
@@ -21,48 +20,11 @@ function buildSummaryTable(specs) {
21
20
  const rows = specs.map((s) => `| ${s.id} | ${s.title} | ${s.status} | ${s.type} | ${s.tags.join(', ')} |`);
22
21
  return [header, divider, ...rows].join('\n');
23
22
  }
24
- async function autoDiscoverProject(projectId, projectPath, collector) {
23
+ async function collectReadOnlyProjectSignals(projectId, projectPath, collector) {
25
24
  if (discoveredProjects.has(projectId)) {
26
25
  return;
27
26
  }
28
27
  discoveredProjects.add(projectId);
29
- try {
30
- await withAudit(projectPath, 'list_specs', 'discoverAndFlattenSpecs', () => discoverAndFlattenSpecs(projectPath));
31
- }
32
- catch {
33
- /* best-effort — don't fail list_specs */
34
- }
35
- try {
36
- await withAudit(projectPath, 'list_specs', 'importFilesystemSpecs', () => importFilesystemSpecs(projectPath, {
37
- listSpecs: specStore.listSpecs,
38
- createSpec: specStore.createSpec,
39
- }, projectId), (imported) => ({ imported: Array.isArray(imported) ? imported.length : 0 }));
40
- }
41
- catch {
42
- /* best-effort */
43
- }
44
- try {
45
- await withAudit(projectPath, 'list_specs', 'reconcileSpecPaths', () => reconcileSpecPaths(projectPath, {
46
- listSpecs: specStore.listSpecs,
47
- updateSpec: specStore.updateSpec,
48
- }, projectId));
49
- }
50
- catch {
51
- /* best-effort */
52
- }
53
- try {
54
- const allStoredSpecs = await specStore.listSpecs(projectId);
55
- const unprefixed = filterUnprefixedSpecs(allStoredSpecs);
56
- if (unprefixed.length > 0) {
57
- await withAudit(projectPath, 'list_specs', 'migrateSpecFolderNames', () => migrateSpecFolderNames(projectPath, unprefixed, {
58
- listSpecs: specStore.listSpecs,
59
- updateSpec: specStore.updateSpec,
60
- }), (migrated) => ({ count: Array.isArray(migrated) ? migrated.length : 0 }));
61
- }
62
- }
63
- catch {
64
- /* best-effort */
65
- }
66
28
  // SPEC-1017: list_specs enforces the strict planu/ policy in check mode.
67
29
  // Mutating cleanup is handled by init/update/validate/housekeeping; list_specs
68
30
  // stays read-mostly and surfaces exact offenders if any remain.
@@ -88,21 +50,6 @@ async function autoDiscoverProject(projectId, projectPath, collector) {
88
50
  catch {
89
51
  /* best-effort — never block list_specs */
90
52
  }
91
- try {
92
- const { configureGitignoreForPlanu } = await import('./init-project/git-setup.js');
93
- await withAudit(projectPath, 'list_specs', 'configureGitignoreForPlanu', () => configureGitignoreForPlanu(projectPath));
94
- collector.pushOk('gitignore', 'Updated .gitignore with planu/ rules');
95
- }
96
- catch {
97
- /* best-effort */
98
- }
99
- // SPEC-493: Silently repair status.json totalSpecs count from actual store data
100
- try {
101
- await withAudit(projectPath, 'list_specs', 'refreshProjectStatus', () => refreshProjectStatus(projectPath, projectId));
102
- }
103
- catch {
104
- /* best-effort */
105
- }
106
53
  }
107
54
  export async function handleListSpecs(params) {
108
55
  // SPEC-584: list_specs is read-mostly and its migration-issue questions are opt-in.
@@ -152,9 +99,10 @@ export async function handleListSpecs(params) {
152
99
  if (isNativeActive() && knowledge.projectPath) {
153
100
  nativeBriefs = fastScanSpecs(knowledge.projectPath);
154
101
  }
155
- // Auto-discover, flatten, rename, and fix specs (once per session per project)
102
+ // Read-only project signals only. Mutating discovery/migration/repair belongs
103
+ // to init, validate, housekeeping, or explicit migration tools.
156
104
  if (knowledge.projectPath) {
157
- await autoDiscoverProject(projectId, knowledge.projectPath, collector);
105
+ await collectReadOnlyProjectSignals(projectId, knowledge.projectPath, collector);
158
106
  }
159
107
  // Get all specs
160
108
  let specs = await specStore.listSpecs(projectId);
@@ -247,6 +195,8 @@ export async function handleListSpecs(params) {
247
195
  updatedAt: s.updatedAt,
248
196
  lastModified: lastModifiedMap.get(s.id) ?? '\u2014',
249
197
  }));
198
+ const structuredSpecs = specsData.slice(0, MAX_STRUCTURED_SPECS);
199
+ const specsOmitted = Math.max(0, specsData.length - structuredSpecs.length);
250
200
  // Build text content: markdown table in summary mode, JSON in full mode
251
201
  let textContent;
252
202
  if (detail === 'summary') {
@@ -262,7 +212,13 @@ export async function handleListSpecs(params) {
262
212
  },
263
213
  count: specs.length,
264
214
  totalCount: allSpecs.length,
265
- specs: specsData,
215
+ specs: structuredSpecs,
216
+ pagination: {
217
+ limit: MAX_STRUCTURED_SPECS,
218
+ returned: structuredSpecs.length,
219
+ total: specsData.length,
220
+ omitted: specsOmitted,
221
+ },
266
222
  summary: {
267
223
  byStatus: statusCounts,
268
224
  byType: typeCounts,
@@ -1,8 +1,21 @@
1
1
  import type { ToolResult } from '../types/index.js';
2
+ export interface StructuredContentCompactionOptions {
3
+ maxArrayItems?: number;
4
+ maxStringChars?: number;
5
+ maxObjectKeys?: number;
6
+ maxDepth?: number;
7
+ }
8
+ /**
9
+ * Compacts structuredContent without changing its broad contract shape.
10
+ * Arrays remain arrays, objects remain objects, and compaction metadata is added
11
+ * at top level when a successful response would otherwise return a large payload.
12
+ */
13
+ export declare function compactStructuredContent(structuredContent: Record<string, unknown>, options?: StructuredContentCompactionOptions): Record<string, unknown>;
2
14
  /**
3
15
  * Post-processes a ToolResult to compress verbose content blocks.
4
16
  * Applied to ALL tool outputs via safeWithTelemetry.
5
- * Preserves structuredContent untouched only compresses text blocks.
17
+ * Also bounds structuredContent so token recording reflects the actual visible
18
+ * payload instead of unbounded internal data.
6
19
  */
7
20
  export declare function compressToolOutput(result: ToolResult): ToolResult;
8
21
  //# sourceMappingURL=output-compressor.d.ts.map
@@ -9,6 +9,14 @@ const MIN_COMPRESS_LENGTH = 800;
9
9
  const HARD_CAP_CHARS = 2400;
10
10
  /** Max lines kept from a long markdown text block */
11
11
  const MAX_MARKDOWN_LINES = 40;
12
+ /** Default number of structured array items to keep in MCP responses. */
13
+ const MAX_STRUCTURED_ARRAY_ITEMS = 20;
14
+ /** Default max string length inside structuredContent. */
15
+ const MAX_STRUCTURED_STRING_CHARS = 2000;
16
+ /** Default object key budget inside structuredContent. */
17
+ const MAX_STRUCTURED_OBJECT_KEYS = 60;
18
+ /** Default recursion depth for structuredContent compaction. */
19
+ const MAX_STRUCTURED_DEPTH = 8;
12
20
  /**
13
21
  * Detects if a string is a JSON dump (starts with { or [).
14
22
  * Quick heuristic — avoids parsing large strings unnecessarily.
@@ -133,10 +141,114 @@ function compressTextBlock(text) {
133
141
  }
134
142
  return hardCap(truncateMarkdown(text));
135
143
  }
144
+ function pushArrayMeta(meta, value) {
145
+ meta.arrays.push(value);
146
+ }
147
+ function pushStringMeta(meta, value) {
148
+ meta.strings.push(value);
149
+ }
150
+ function pushObjectMeta(meta, value) {
151
+ meta.objects.push(value);
152
+ }
153
+ function pushDepthMeta(meta, path) {
154
+ meta.depthLimited.push(path);
155
+ }
156
+ function compactStructuredValue(value, path, depth, options, meta) {
157
+ if (typeof value === 'string') {
158
+ if (value.length <= options.maxStringChars) {
159
+ return value;
160
+ }
161
+ pushStringMeta(meta, {
162
+ path,
163
+ originalChars: value.length,
164
+ shownChars: options.maxStringChars,
165
+ });
166
+ return `${value.slice(0, options.maxStringChars)}\n... (${String(value.length - options.maxStringChars)} chars omitted)`;
167
+ }
168
+ if (value === null || typeof value !== 'object') {
169
+ return value;
170
+ }
171
+ if (depth >= options.maxDepth) {
172
+ pushDepthMeta(meta, path);
173
+ return '[structuredContent depth limit reached]';
174
+ }
175
+ if (Array.isArray(value)) {
176
+ const shown = value.slice(0, options.maxArrayItems);
177
+ if (value.length > shown.length) {
178
+ pushArrayMeta(meta, {
179
+ path,
180
+ total: value.length,
181
+ shown: shown.length,
182
+ omitted: value.length - shown.length,
183
+ });
184
+ }
185
+ return shown.map((item, index) => compactStructuredValue(item, `${path}[${String(index)}]`, depth + 1, options, meta));
186
+ }
187
+ const entries = Object.entries(value);
188
+ const keptEntries = entries.slice(0, options.maxObjectKeys);
189
+ if (entries.length > keptEntries.length) {
190
+ pushObjectMeta(meta, {
191
+ path,
192
+ totalKeys: entries.length,
193
+ shownKeys: keptEntries.length,
194
+ omitted: entries.length - keptEntries.length,
195
+ });
196
+ }
197
+ const out = {};
198
+ for (const [key, entryValue] of keptEntries) {
199
+ out[key] = compactStructuredValue(entryValue, path === '$' ? `$.${key}` : `${path}.${key}`, depth + 1, options, meta);
200
+ }
201
+ return out;
202
+ }
203
+ /**
204
+ * Compacts structuredContent without changing its broad contract shape.
205
+ * Arrays remain arrays, objects remain objects, and compaction metadata is added
206
+ * at top level when a successful response would otherwise return a large payload.
207
+ */
208
+ export function compactStructuredContent(structuredContent, options = {}) {
209
+ const resolved = {
210
+ maxArrayItems: options.maxArrayItems ?? MAX_STRUCTURED_ARRAY_ITEMS,
211
+ maxStringChars: options.maxStringChars ?? MAX_STRUCTURED_STRING_CHARS,
212
+ maxObjectKeys: options.maxObjectKeys ?? MAX_STRUCTURED_OBJECT_KEYS,
213
+ maxDepth: options.maxDepth ?? MAX_STRUCTURED_DEPTH,
214
+ };
215
+ const meta = {
216
+ structuredContentCompacted: true,
217
+ arrays: [],
218
+ strings: [],
219
+ objects: [],
220
+ depthLimited: [],
221
+ };
222
+ const compacted = compactStructuredValue(structuredContent, '$', 0, resolved, meta);
223
+ if (meta.arrays.length === 0 &&
224
+ meta.strings.length === 0 &&
225
+ meta.objects.length === 0 &&
226
+ meta.depthLimited.length === 0) {
227
+ return structuredContent;
228
+ }
229
+ if (compacted !== null && typeof compacted === 'object' && !Array.isArray(compacted)) {
230
+ const obj = compacted;
231
+ const existingCompaction = obj.compaction !== null && typeof obj.compaction === 'object'
232
+ ? obj.compaction
233
+ : {};
234
+ return {
235
+ ...obj,
236
+ compaction: {
237
+ ...existingCompaction,
238
+ ...meta,
239
+ },
240
+ };
241
+ }
242
+ return {
243
+ value: compacted,
244
+ compaction: meta,
245
+ };
246
+ }
136
247
  /**
137
248
  * Post-processes a ToolResult to compress verbose content blocks.
138
249
  * Applied to ALL tool outputs via safeWithTelemetry.
139
- * Preserves structuredContent untouched only compresses text blocks.
250
+ * Also bounds structuredContent so token recording reflects the actual visible
251
+ * payload instead of unbounded internal data.
140
252
  */
141
253
  export function compressToolOutput(result) {
142
254
  // Don't compress error results — they need full context for debugging
@@ -151,6 +263,13 @@ export function compressToolOutput(result) {
151
263
  ...block,
152
264
  text: compressTextBlock(block.text),
153
265
  }));
154
- return { ...result, content: compressed };
266
+ const structuredContent = result.structuredContent !== undefined
267
+ ? compactStructuredContent(result.structuredContent)
268
+ : undefined;
269
+ return {
270
+ ...result,
271
+ content: compressed,
272
+ ...(structuredContent !== undefined ? { structuredContent } : {}),
273
+ };
155
274
  }
156
275
  //# sourceMappingURL=output-compressor.js.map