@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.
- package/CHANGELOG.md +11 -1
- package/dist/engine/compact/tool-list-compactor.d.ts +1 -1
- package/dist/engine/evidence-gates/lifecycle-gate.js +17 -1
- package/dist/engine/evidence-index/done-drift.js +4 -1
- package/dist/engine/local-first/tool-classification.d.ts +6 -0
- package/dist/engine/local-first/tool-classification.js +119 -0
- package/dist/engine/qa-gate.js +13 -1
- package/dist/engine/scope-boundaries/scope-validator.js +14 -2
- package/dist/engine/validator/validation-report-writer.js +3 -4
- package/dist/tools/list-specs.js +15 -59
- package/dist/tools/output-compressor.d.ts +14 -1
- package/dist/tools/output-compressor.js +121 -2
- package/dist/tools/package-handoff.js +136 -12
- package/dist/tools/status-handler.js +6 -1
- package/dist/tools/token-recording.d.ts +2 -1
- package/dist/tools/token-recording.js +21 -2
- package/dist/tools/update-status/dod-gates.d.ts +2 -2
- package/dist/tools/update-status/dod-gates.js +8 -28
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status/qa-gate.d.ts +5 -0
- package/dist/tools/update-status/qa-gate.js +50 -0
- package/dist/tools/update-status/response-builder.js +96 -2
- package/dist/tools/update-status-convention-gate.js +22 -6
- package/dist/tools/validate.js +137 -16
- package/dist/transports/transport-factory.js +13 -0
- package/dist/types/qa-gate.d.ts +3 -0
- package/dist/types/tool-classification.d.ts +8 -0
- package/dist/types/tool-classification.js +2 -0
- package/package.json +1 -1
- package/planu-native.json +8 -29
- 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
|
|
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:
|
|
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
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
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
|
|
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
|
|
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) =>
|
|
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' ||
|
package/dist/tools/list-specs.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|