@planu/cli 4.7.2 → 4.7.3

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 CHANGED
@@ -1,3 +1,12 @@
1
+ ## [4.7.3] - 2026-06-19
2
+
3
+ ### Features
4
+ - feat: add reversible context compaction
5
+
6
+ ### Chores
7
+ - chore(deps): update patch and minor dependencies
8
+
9
+
1
10
  ## [4.7.2] - 2026-06-16
2
11
 
3
12
  ### Features
@@ -4094,4 +4103,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
4094
4103
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
4095
4104
  - Multi-language i18n (EN/ES/PT) for generated specs
4096
4105
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
4097
- - 10,857 tests with ≥95% coverage
4106
+ - 10,857 tests with ≥95% coverage
@@ -17,6 +17,22 @@
17
17
  "generic": { "maxLines": 60 }
18
18
  }
19
19
  },
20
+ "contextArtifacts": {
21
+ "enabled": true,
22
+ "ttlMs": 86400000,
23
+ "minTokens": 200
24
+ },
25
+ "contentCompaction": {
26
+ "strategies": {
27
+ "json": { "maxLines": 60, "maxSnippetChars": 240 },
28
+ "test-log": { "maxLines": 80, "keepFailures": true, "maxSnippetChars": 240 },
29
+ "runtime-log": { "maxLines": 60, "keepFailures": true, "uniqueOnly": true, "maxSnippetChars": 240 },
30
+ "search-results": { "maxLines": 80, "uniqueOnly": true, "maxSnippetChars": 180 },
31
+ "code": { "maxLines": 120, "maxSnippetChars": 240 },
32
+ "spec-or-handoff": { "maxLines": 120, "maxSnippetChars": 240 },
33
+ "generic-text": { "maxLines": 60, "maxSnippetChars": 240 }
34
+ }
35
+ },
20
36
  "tools": {
21
37
  "groups": {
22
38
  "spec": ["create_spec", "check_readiness", "challenge_spec", "update_status"],
@@ -1,10 +1,10 @@
1
1
  import type { ToolResult } from '../../types/index.js';
2
- import type { CompactDecision } from '../../types/compact/compact-mode.js';
2
+ import type { CompactDecision, CompactModeOptions } from '../../types/compact/compact-mode.js';
3
3
  /**
4
4
  * Apply compact mode formatting to a ToolResult.
5
5
  * - Truncates text content blocks to tokenBudget
6
6
  * - Preserves structuredContent (essential data: status, scores, blockers)
7
7
  * - Adds _meta.compactMode and _meta.contextUsed
8
8
  */
9
- export declare function applyCompactMode(result: ToolResult, decision: CompactDecision): ToolResult;
9
+ export declare function applyCompactMode(result: ToolResult, decision: CompactDecision, options?: CompactModeOptions): ToolResult;
10
10
  //# sourceMappingURL=compact-middleware.d.ts.map
@@ -1,6 +1,47 @@
1
1
  // engine/compact/compact-middleware.ts — SPEC-922: Apply compact mode to tool responses
2
+ import { compactContentAware } from '../token-optimizer/content-aware-compactor.js';
2
3
  /** Approximate chars per token for English text. */
3
4
  const CHARS_PER_TOKEN = 4;
5
+ function defaultCompactPolicy(tokenBudget) {
6
+ return {
7
+ contextArtifacts: {
8
+ enabled: true,
9
+ ttlMs: 24 * 60 * 60 * 1000,
10
+ minTokens: tokenBudget,
11
+ },
12
+ contentCompaction: {
13
+ strategies: {
14
+ json: { maxLines: tokenBudget },
15
+ 'test-log': { maxLines: tokenBudget },
16
+ 'runtime-log': { maxLines: tokenBudget },
17
+ 'search-results': { maxLines: tokenBudget },
18
+ code: { maxLines: tokenBudget },
19
+ 'spec-or-handoff': { maxLines: tokenBudget },
20
+ 'generic-text': { maxLines: tokenBudget },
21
+ },
22
+ },
23
+ redaction: {
24
+ maxSnippetChars: 240,
25
+ redactPatterns: ['secret', 'token', 'password', 'api_key'],
26
+ },
27
+ };
28
+ }
29
+ function withCompactionMeta(result, compaction) {
30
+ if (compaction === undefined) {
31
+ return result;
32
+ }
33
+ return {
34
+ ...result,
35
+ structuredContent: {
36
+ ...(result.structuredContent ?? {}),
37
+ compaction,
38
+ },
39
+ _meta: {
40
+ ...(typeof result._meta === 'object' && result._meta !== null ? result._meta : {}),
41
+ compaction,
42
+ },
43
+ };
44
+ }
4
45
  /** Truncate a single text block to a token budget. */
5
46
  function truncateBlock(text, tokenBudget) {
6
47
  const maxChars = tokenBudget * CHARS_PER_TOKEN;
@@ -17,7 +58,7 @@ function truncateBlock(text, tokenBudget) {
17
58
  * - Preserves structuredContent (essential data: status, scores, blockers)
18
59
  * - Adds _meta.compactMode and _meta.contextUsed
19
60
  */
20
- export function applyCompactMode(result, decision) {
61
+ export function applyCompactMode(result, decision, options = {}) {
21
62
  if (decision.mode !== 'compact') {
22
63
  // Still add _meta even in verbose mode if contextUsed was provided
23
64
  if (decision.contextUsed !== undefined) {
@@ -32,11 +73,31 @@ export function applyCompactMode(result, decision) {
32
73
  }
33
74
  return result;
34
75
  }
35
- const truncatedContent = result.content.map((block) => ({
36
- ...block,
37
- text: truncateBlock(block.text, decision.tokenBudget),
38
- }));
39
- return {
76
+ let firstCompaction;
77
+ const truncatedContent = result.content.map((block) => {
78
+ if (options.projectPath !== undefined) {
79
+ const compacted = compactContentAware({
80
+ text: block.text,
81
+ policy: defaultCompactPolicy(decision.tokenBudget),
82
+ projectPath: options.projectPath,
83
+ sourcePath: options.sourcePath,
84
+ flow: options.flow,
85
+ kind: options.flow,
86
+ });
87
+ firstCompaction ??= compacted.artifact;
88
+ return {
89
+ ...block,
90
+ text: compacted.text.length < block.text.length
91
+ ? `${compacted.text}\n\nArtifact: ${compacted.artifact?.artifactRef ?? 'not stored'}`
92
+ : truncateBlock(block.text, decision.tokenBudget),
93
+ };
94
+ }
95
+ return {
96
+ ...block,
97
+ text: truncateBlock(block.text, decision.tokenBudget),
98
+ };
99
+ });
100
+ return withCompactionMeta({
40
101
  ...result,
41
102
  content: truncatedContent,
42
103
  _meta: {
@@ -45,6 +106,6 @@ export function applyCompactMode(result, decision) {
45
106
  contextUsed: decision.contextUsed,
46
107
  tokenBudget: decision.tokenBudget,
47
108
  },
48
- };
109
+ }, firstCompaction);
49
110
  }
50
111
  //# sourceMappingURL=compact-middleware.js.map
@@ -0,0 +1,2 @@
1
+ export { getContextArtifactStats, retrieveContextArtifact, storeContextArtifact } from './store.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { getContextArtifactStats, retrieveContextArtifact, storeContextArtifact } from './store.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ import type { ContextArtifactStats, RetrieveContextArtifactResult, StoreContextArtifactInput, StoreContextArtifactResult } from '../../types/context-artifacts.js';
2
+ export declare function storeContextArtifact(input: StoreContextArtifactInput): StoreContextArtifactResult;
3
+ export declare function retrieveContextArtifact(projectPath: string, ref: string): RetrieveContextArtifactResult;
4
+ export declare function getContextArtifactStats(projectPath: string): ContextArtifactStats;
5
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getSensitivePathRefusal, shouldBypassLeanMode, } from '../context-intelligence/compression-guards.js';
5
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
6
+ const REF_PREFIX = 'ctx_';
7
+ const REF_PATTERN = /^ctx_[a-f0-9]{32}$/;
8
+ const FORBIDDEN_METADATA_KEY = /(?:secret|token|password|credential|api[_-]?key|private[_-]?key)/i;
9
+ function artifactDir(projectPath) {
10
+ return join(projectDataDir(hashProjectPath(projectPath)), 'context-artifacts');
11
+ }
12
+ function artifactPath(projectPath, ref) {
13
+ if (!REF_PATTERN.test(ref)) {
14
+ return null;
15
+ }
16
+ return join(artifactDir(projectPath), `${ref}.json`);
17
+ }
18
+ function sha256(value) {
19
+ return createHash('sha256').update(value, 'utf8').digest('hex');
20
+ }
21
+ function safeMetadata(metadata) {
22
+ const safe = {};
23
+ for (const [key, value] of Object.entries(metadata ?? {})) {
24
+ if (FORBIDDEN_METADATA_KEY.test(key)) {
25
+ continue;
26
+ }
27
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
28
+ safe[key] =
29
+ typeof value === 'string' && FORBIDDEN_METADATA_KEY.test(value) ? '[redacted]' : value;
30
+ }
31
+ }
32
+ return safe;
33
+ }
34
+ function compactionMetadata(artifact) {
35
+ return {
36
+ artifactRef: artifact.ref,
37
+ originalTokens: artifact.originalTokens,
38
+ compactTokens: artifact.compactTokens,
39
+ tokensSaved: artifact.tokensSaved,
40
+ strategy: artifact.strategy,
41
+ contentType: artifact.contentType,
42
+ expiresAt: artifact.expiresAt,
43
+ retrievalHint: `Use artifactRef ${artifact.ref} to retrieve the full local output before ${artifact.expiresAt}.`,
44
+ };
45
+ }
46
+ function readArtifactFile(path) {
47
+ try {
48
+ return JSON.parse(readFileSync(path, 'utf-8'));
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function writeArtifactFile(path, artifact) {
55
+ writeFileSync(path, JSON.stringify(artifact, null, 2), 'utf-8');
56
+ }
57
+ export function storeContextArtifact(input) {
58
+ if (input.sourcePath) {
59
+ const refusal = getSensitivePathRefusal(input.sourcePath);
60
+ if (refusal !== null) {
61
+ return { stored: false, refusedReason: refusal };
62
+ }
63
+ }
64
+ if (input.flow && shouldBypassLeanMode(input.flow)) {
65
+ return {
66
+ stored: false,
67
+ refusedReason: `Refusing recoverable artifact for sensitive flow ${input.flow}`,
68
+ };
69
+ }
70
+ const contentHash = sha256(input.originalContent);
71
+ const ref = `${REF_PREFIX}${contentHash.slice(0, 32)}`;
72
+ const path = artifactPath(input.projectPath, ref);
73
+ if (path === null) {
74
+ return { stored: false, refusedReason: 'Invalid generated artifact ref' };
75
+ }
76
+ const now = Date.now();
77
+ const createdAt = new Date(now).toISOString();
78
+ const expiresAt = new Date(now + input.ttlMs).toISOString();
79
+ const tokensSaved = Math.max(0, input.originalTokens - input.compactTokens);
80
+ const previous = existsSync(path) ? readArtifactFile(path) : null;
81
+ const artifact = {
82
+ ref,
83
+ contentHash,
84
+ createdAt: previous?.createdAt ?? createdAt,
85
+ expiresAt,
86
+ ttlMs: input.ttlMs,
87
+ projectId: hashProjectPath(input.projectPath),
88
+ contentType: input.contentType,
89
+ strategy: input.strategy,
90
+ originalTokens: input.originalTokens,
91
+ compactTokens: input.compactTokens,
92
+ tokensSaved,
93
+ retrievalCount: previous?.retrievalCount ?? 0,
94
+ metadata: safeMetadata(input.metadata),
95
+ originalContent: input.originalContent,
96
+ compactContent: input.compactContent,
97
+ };
98
+ mkdirSync(artifactDir(input.projectPath), { recursive: true });
99
+ writeArtifactFile(path, artifact);
100
+ return { stored: true, artifact, metadata: compactionMetadata(artifact) };
101
+ }
102
+ export function retrieveContextArtifact(projectPath, ref) {
103
+ const path = artifactPath(projectPath, ref);
104
+ if (path === null) {
105
+ return {
106
+ found: false,
107
+ reason: 'invalid-ref',
108
+ hint: 'Artifact refs are opaque ctx_<hash> identifiers.',
109
+ };
110
+ }
111
+ if (!existsSync(path)) {
112
+ return {
113
+ found: false,
114
+ reason: 'missing',
115
+ hint: 'The artifact is missing or belongs to a different project.',
116
+ };
117
+ }
118
+ const artifact = readArtifactFile(path);
119
+ if (artifact === null) {
120
+ return {
121
+ found: false,
122
+ reason: 'corrupt',
123
+ hint: 'The artifact payload is corrupt and cannot be recovered.',
124
+ };
125
+ }
126
+ if (artifact.projectId !== hashProjectPath(projectPath)) {
127
+ return {
128
+ found: false,
129
+ reason: 'unauthorized',
130
+ hint: 'The artifact does not belong to this project scope.',
131
+ };
132
+ }
133
+ if (Date.parse(artifact.expiresAt) <= Date.now()) {
134
+ return {
135
+ found: false,
136
+ reason: 'expired',
137
+ hint: 'The artifact expired; rerun the source operation if exact output is needed.',
138
+ };
139
+ }
140
+ const updated = { ...artifact, retrievalCount: artifact.retrievalCount + 1 };
141
+ writeArtifactFile(path, updated);
142
+ return {
143
+ found: true,
144
+ artifact: updated,
145
+ hint: 'Full artifact recovered from local Planu storage.',
146
+ };
147
+ }
148
+ export function getContextArtifactStats(projectPath) {
149
+ const dir = artifactDir(projectPath);
150
+ const stats = {
151
+ artifactCount: 0,
152
+ totalOriginalTokens: 0,
153
+ totalCompactTokens: 0,
154
+ totalTokensSaved: 0,
155
+ retrievalCount: 0,
156
+ };
157
+ if (!existsSync(dir)) {
158
+ return stats;
159
+ }
160
+ for (const entry of readdirSync(dir)) {
161
+ if (!entry.endsWith('.json')) {
162
+ continue;
163
+ }
164
+ const artifact = readArtifactFile(join(dir, entry));
165
+ if (artifact === null || Date.parse(artifact.expiresAt) <= Date.now()) {
166
+ continue;
167
+ }
168
+ stats.artifactCount += 1;
169
+ stats.totalOriginalTokens += artifact.originalTokens;
170
+ stats.totalCompactTokens += artifact.compactTokens;
171
+ stats.totalTokensSaved += artifact.tokensSaved;
172
+ stats.retrievalCount += artifact.retrievalCount;
173
+ }
174
+ return stats;
175
+ }
176
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,4 @@
1
+ import type { ContentAwareCompactionInput, ContentAwareCompactionResult, ContextArtifactContentType } from '../../types/context-artifacts.js';
2
+ export declare function classifyContentType(kind: string | undefined, text: string): ContextArtifactContentType;
3
+ export declare function compactContentAware(input: ContentAwareCompactionInput): ContentAwareCompactionResult;
4
+ //# sourceMappingURL=content-aware-compactor.d.ts.map
@@ -0,0 +1,230 @@
1
+ import { countTokens } from './counter.js';
2
+ import { storeContextArtifact } from '../context-artifacts/store.js';
3
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
4
+ const DEFAULT_MIN_TOKENS = 200;
5
+ function redact(text, patterns) {
6
+ return patterns.reduce((current, pattern) => {
7
+ const re = new RegExp(pattern, 'gi');
8
+ return current.replace(re, '[redacted]');
9
+ }, text);
10
+ }
11
+ function escapeInert(text) {
12
+ return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
13
+ }
14
+ function bounded(value, maxChars) {
15
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
16
+ }
17
+ function isLikelyJson(text) {
18
+ const trimmed = text.trim();
19
+ return ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
20
+ (trimmed.startsWith('[') && trimmed.endsWith(']')));
21
+ }
22
+ function isSearchResult(text) {
23
+ return text
24
+ .split('\n')
25
+ .some((line) => /^[^:\n]+:\d+(?::\d+)?:/.test(line) || /^[^:\n]+\(\d+,\d+\):/.test(line));
26
+ }
27
+ function isCode(text) {
28
+ const lines = text.split('\n');
29
+ const codeLines = lines.filter((line) => /^\s*(import|export|function|class|interface|type |const |let |var |def |fn |pub |func |struct |enum )/.test(line));
30
+ return lines.length > 0 && codeLines.length / lines.length > 0.12;
31
+ }
32
+ function isSpecOrHandoff(text) {
33
+ return (/\bSPEC-\d+\b/.test(text) ||
34
+ /\b(acceptance criteria|handoff|validation score|next action)\b/i.test(text));
35
+ }
36
+ export function classifyContentType(kind, text) {
37
+ const normalized = kind?.toLowerCase() ?? '';
38
+ if (normalized.includes('json') || isLikelyJson(text)) {
39
+ return 'json';
40
+ }
41
+ if (normalized.includes('test') ||
42
+ /\b(vitest|jest|failed tests?|test files?|assertionerror)\b/i.test(text)) {
43
+ return 'test-log';
44
+ }
45
+ if (normalized.includes('log') ||
46
+ /\b(error|warn|exception|stack trace|exit code)\b/i.test(text)) {
47
+ return 'runtime-log';
48
+ }
49
+ if (normalized.includes('search') || isSearchResult(text)) {
50
+ return 'search-results';
51
+ }
52
+ if (normalized.includes('code') || isCode(text)) {
53
+ return 'code';
54
+ }
55
+ if (normalized.includes('spec') || normalized.includes('handoff') || isSpecOrHandoff(text)) {
56
+ return 'spec-or-handoff';
57
+ }
58
+ return 'generic-text';
59
+ }
60
+ function summarizeJsonValue(value, depth = 0) {
61
+ if (value === null || typeof value === 'boolean' || typeof value === 'number') {
62
+ return value;
63
+ }
64
+ if (typeof value === 'string') {
65
+ return value.length <= 80 ? value : `${value.slice(0, 80)}...`;
66
+ }
67
+ if (Array.isArray(value)) {
68
+ return {
69
+ length: value.length,
70
+ sample: value.slice(0, 3).map((item) => summarizeJsonValue(item, depth + 1)),
71
+ };
72
+ }
73
+ if (typeof value === 'object') {
74
+ const out = {};
75
+ for (const [key, child] of Object.entries(value).slice(0, depth > 1 ? 12 : 40)) {
76
+ if (/^(status|state|count|total|id|uuid|name|ok|success|error|errors|warning|warnings)$/i.test(key)) {
77
+ out[key] = summarizeJsonValue(child, depth + 1);
78
+ }
79
+ else if (depth < 2) {
80
+ out[key] = summarizeJsonValue(child, depth + 1);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ if (typeof value === 'undefined') {
86
+ return 'undefined';
87
+ }
88
+ if (typeof value === 'bigint') {
89
+ return value.toString();
90
+ }
91
+ if (typeof value === 'symbol') {
92
+ return value.description ?? 'symbol';
93
+ }
94
+ return '[unsupported]';
95
+ }
96
+ function compactJson(text) {
97
+ try {
98
+ const parsed = JSON.parse(text);
99
+ return JSON.stringify(summarizeJsonValue(parsed), null, 2);
100
+ }
101
+ catch {
102
+ return compactGenericText(text);
103
+ }
104
+ }
105
+ function compactLog(text, maxLines) {
106
+ const lines = text.split('\n');
107
+ const important = lines.filter((line) => /\b(error|fail|failed|failure|exception|stack|trace|warning|warn|duration|total|passed|exit code|exit status)\b/i.test(line));
108
+ const selected = important.length > 0 ? important : lines;
109
+ return [...new Set(selected)].slice(0, maxLines).join('\n');
110
+ }
111
+ function compactSearchResults(text, maxLines, maxSnippetChars) {
112
+ const grouped = new Map();
113
+ for (const line of text.split('\n')) {
114
+ const match = /^([^:\n]+):(\d+)(?::\d+)?:\s?(.*)$/.exec(line);
115
+ if (!match) {
116
+ continue;
117
+ }
118
+ const [, path, lineNumber, snippet] = match;
119
+ if (path === undefined || lineNumber === undefined) {
120
+ continue;
121
+ }
122
+ const entries = grouped.get(path) ?? [];
123
+ entries.push(` ${lineNumber}: ${bounded(snippet ?? '', maxSnippetChars)}`);
124
+ grouped.set(path, [...new Set(entries)]);
125
+ }
126
+ if (grouped.size === 0) {
127
+ return compactGenericText(text);
128
+ }
129
+ const out = [];
130
+ for (const [path, entries] of grouped.entries()) {
131
+ out.push(path, ...entries.slice(0, maxLines));
132
+ }
133
+ return out.slice(0, maxLines).join('\n');
134
+ }
135
+ function bracesBalanced(text) {
136
+ let balance = 0;
137
+ for (const char of text) {
138
+ if (char === '{') {
139
+ balance += 1;
140
+ }
141
+ else if (char === '}') {
142
+ balance -= 1;
143
+ }
144
+ if (balance < 0) {
145
+ return false;
146
+ }
147
+ }
148
+ return balance === 0;
149
+ }
150
+ function compactCode(text) {
151
+ if (!bracesBalanced(text)) {
152
+ return compactGenericText(text);
153
+ }
154
+ const preserved = text
155
+ .split('\n')
156
+ .filter((line) => /^\s*(import|export|interface|type |class |function |async function|const [A-Z0-9_]+|enum |struct |def |fn |pub )/.test(line));
157
+ return preserved.length > 0 ? preserved.join('\n') : compactGenericText(text);
158
+ }
159
+ function compactSpecOrHandoff(text) {
160
+ const lines = text.split('\n');
161
+ const important = lines.filter((line) => /\b(SPEC-\d+|title:|status|acceptance criteria|blocker|blocked|files?|validation score|risk|security|privacy|next action|implementation|handoff)\b/i.test(line) ||
162
+ /^-\s*\[[ xX]\]\s+/.test(line) ||
163
+ /^#{1,3}\s/.test(line));
164
+ return (important.length > 0 ? important : lines).slice(0, 80).join('\n');
165
+ }
166
+ function compactGenericText(text) {
167
+ const lines = text.split('\n').filter((line) => line.trim().length > 0);
168
+ const important = lines.filter((line) => /^#{1,3}\s/.test(line) ||
169
+ /\b(error|failed|blocked|warning|todo|next action|pnpm|npm|git|https?:\/\/|\w+\/[\w./-]+\.\w+)\b/i.test(line));
170
+ return (important.length > 0 ? important : lines).slice(0, 60).join('\n');
171
+ }
172
+ function compactByType(type, text, maxLines, maxSnippetChars) {
173
+ switch (type) {
174
+ case 'json':
175
+ return compactJson(text);
176
+ case 'test-log':
177
+ case 'runtime-log':
178
+ return compactLog(text, maxLines);
179
+ case 'search-results':
180
+ return compactSearchResults(text, maxLines, maxSnippetChars);
181
+ case 'code':
182
+ return compactCode(text);
183
+ case 'spec-or-handoff':
184
+ return compactSpecOrHandoff(text);
185
+ case 'generic-text':
186
+ return compactGenericText(text);
187
+ }
188
+ }
189
+ export function compactContentAware(input) {
190
+ const contentType = classifyContentType(input.kind, input.text);
191
+ const strategyConfig = input.policy.contentCompaction?.strategies?.[contentType];
192
+ const maxLines = strategyConfig?.maxLines ?? 60;
193
+ const maxSnippetChars = strategyConfig?.maxSnippetChars ?? input.policy.redaction.maxSnippetChars;
194
+ const redacted = redact(input.text, input.policy.redaction.redactPatterns);
195
+ const compact = escapeInert(compactByType(contentType, redacted, maxLines, maxSnippetChars));
196
+ const originalTokens = countTokens(input.text).tokens;
197
+ const compactTokens = countTokens(compact).tokens;
198
+ const result = {
199
+ text: compact,
200
+ originalTokens,
201
+ compactTokens,
202
+ tokensSaved: Math.max(0, originalTokens - compactTokens),
203
+ strategy: contentType,
204
+ contentType,
205
+ };
206
+ const artifactsEnabled = input.policy.contextArtifacts?.enabled !== false;
207
+ const minTokens = input.policy.contextArtifacts?.minTokens ?? DEFAULT_MIN_TOKENS;
208
+ if (!artifactsEnabled || input.projectPath === undefined || originalTokens < minTokens) {
209
+ return result;
210
+ }
211
+ const stored = storeContextArtifact({
212
+ projectPath: input.projectPath,
213
+ originalContent: input.text,
214
+ compactContent: compact,
215
+ contentType,
216
+ strategy: contentType,
217
+ originalTokens,
218
+ compactTokens,
219
+ ttlMs: input.policy.contextArtifacts?.ttlMs ?? DEFAULT_TTL_MS,
220
+ sourcePath: input.sourcePath,
221
+ flow: input.flow,
222
+ metadata: input.metadata,
223
+ });
224
+ return {
225
+ ...result,
226
+ ...(stored.metadata !== undefined ? { artifact: stored.metadata } : {}),
227
+ ...(stored.refusedReason !== undefined ? { refusedReason: stored.refusedReason } : {}),
228
+ };
229
+ }
230
+ //# sourceMappingURL=content-aware-compactor.js.map
@@ -9,6 +9,7 @@ export { OptimizationReporter } from './reporter.js';
9
9
  export { TokenOptimizer } from './optimizer.js';
10
10
  export { aggregateEntries, computeDailyBreakdown, detectTrend, detectAnomalies, computeBudgetStatus, getTopConsumers, computeOptimizationSavings, } from './analytics.js';
11
11
  export { loadTokenWastePolicy } from './policy-loader.js';
12
+ export { classifyContentType, compactContentAware } from './content-aware-compactor.js';
12
13
  export { analyzeContextPreflight } from './context-preflight.js';
13
14
  export { filterVerboseOutput } from './output-filter.js';
14
15
  export { recommendRelevantTools, toolsFromPolicyGroups } from './tool-relevance.js';
@@ -10,6 +10,7 @@ export { OptimizationReporter } from './reporter.js';
10
10
  export { TokenOptimizer } from './optimizer.js';
11
11
  export { aggregateEntries, computeDailyBreakdown, detectTrend, detectAnomalies, computeBudgetStatus, getTopConsumers, computeOptimizationSavings, } from './analytics.js';
12
12
  export { loadTokenWastePolicy } from './policy-loader.js';
13
+ export { classifyContentType, compactContentAware } from './content-aware-compactor.js';
13
14
  export { analyzeContextPreflight } from './context-preflight.js';
14
15
  export { filterVerboseOutput } from './output-filter.js';
15
16
  export { recommendRelevantTools, toolsFromPolicyGroups } from './tool-relevance.js';
@@ -1,3 +1,4 @@
1
+ import { compactContentAware } from './content-aware-compactor.js';
1
2
  function escapeInert(text) {
2
3
  return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
3
4
  }
@@ -46,7 +47,21 @@ export function filterVerboseOutput(input) {
46
47
  const strategy = selectStrategy(input);
47
48
  const originalLines = input.text.split('\n').length;
48
49
  const lines = compactLines(input, strategy);
49
- const compact = redact(escapeInert(lines.join('\n')), input.policy.redaction.redactPatterns);
50
+ let compact = redact(escapeInert(lines.join('\n')), input.policy.redaction.redactPatterns);
51
+ let compaction;
52
+ if (input.policy.contextArtifacts?.enabled === true && input.projectPath !== undefined) {
53
+ const contentAware = compactContentAware({
54
+ text: input.text,
55
+ policy: input.policy,
56
+ kind: input.kind,
57
+ projectPath: input.projectPath,
58
+ sourcePath: input.sourcePath,
59
+ flow: input.flow,
60
+ metadata: input.metadata,
61
+ });
62
+ compact = contentAware.text;
63
+ compaction = contentAware.artifact;
64
+ }
50
65
  const decisions = [
51
66
  {
52
67
  decision: originalLines > lines.length ? 'summarize' : 'include',
@@ -59,8 +74,9 @@ export function filterVerboseOutput(input) {
59
74
  return {
60
75
  text: compact,
61
76
  originalLines,
62
- returnedLines: lines.length,
77
+ returnedLines: compact.split('\n').length,
63
78
  ...(input.fullOutputRef !== undefined ? { fullOutputRef: input.fullOutputRef } : {}),
79
+ ...(compaction !== undefined ? { compaction } : {}),
64
80
  decisions,
65
81
  };
66
82
  }
@@ -39,6 +39,18 @@ function validatePolicy(value) {
39
39
  if (!isRecord(policy.outputs.strategies)) {
40
40
  throw new Error('Invalid token waste policy at outputs.strategies: expected object');
41
41
  }
42
+ if (policy.contextArtifacts !== undefined) {
43
+ if (!Number.isFinite(policy.contextArtifacts.ttlMs) || policy.contextArtifacts.ttlMs < 1) {
44
+ throw new Error('Invalid token waste policy at contextArtifacts.ttlMs: expected positive number');
45
+ }
46
+ if (!Number.isFinite(policy.contextArtifacts.minTokens) ||
47
+ policy.contextArtifacts.minTokens < 1) {
48
+ throw new Error('Invalid token waste policy at contextArtifacts.minTokens: expected positive number');
49
+ }
50
+ }
51
+ if (policy.contentCompaction !== undefined && !isRecord(policy.contentCompaction.strategies)) {
52
+ throw new Error('Invalid token waste policy at contentCompaction.strategies: expected object');
53
+ }
42
54
  if (!isRecord(policy.tools.groups)) {
43
55
  throw new Error('Invalid token waste policy at tools.groups: expected object');
44
56
  }
@@ -18,6 +18,10 @@ export declare class OptimizationReporter {
18
18
  * Record a cache hit for a tool.
19
19
  */
20
20
  recordCacheHit(toolName: string, tokensSaved: number): void;
21
+ /**
22
+ * Record measured savings from reversible compaction.
23
+ */
24
+ recordCompaction(toolName: string, tokensSaved: number, retrievals?: number): void;
21
25
  /**
22
26
  * Record a cache miss for a tool.
23
27
  */
@@ -28,6 +28,16 @@ export class OptimizationReporter {
28
28
  record.tokensSaved += tokensSaved;
29
29
  this.totalSaved += tokensSaved;
30
30
  }
31
+ /**
32
+ * Record measured savings from reversible compaction.
33
+ */
34
+ recordCompaction(toolName, tokensSaved, retrievals = 0) {
35
+ const record = this.getOrCreateRecord(toolName);
36
+ record.compactionTokensSaved = (record.compactionTokensSaved ?? 0) + tokensSaved;
37
+ record.artifactRetrievals = (record.artifactRetrievals ?? 0) + retrievals;
38
+ record.tokensSaved += tokensSaved;
39
+ this.totalSaved += tokensSaved;
40
+ }
31
41
  /**
32
42
  * Record a cache miss for a tool.
33
43
  */
@@ -106,7 +116,8 @@ export class OptimizationReporter {
106
116
  for (const record of toolRecords) {
107
117
  lines.push(`- ${record.toolName}: ${String(record.totalTokens)} tokens, ` +
108
118
  `${String(record.callCount)} calls, ` +
109
- `${String(record.tokensSaved)} saved`);
119
+ `${String(record.tokensSaved)} saved ` +
120
+ `(compaction ${String(record.compactionTokensSaved ?? 0)}, retrievals ${String(record.artifactRetrievals ?? 0)})`);
110
121
  }
111
122
  }
112
123
  return lines.join('\n');
@@ -134,6 +145,8 @@ export class OptimizationReporter {
134
145
  cacheHits: 0,
135
146
  cacheMisses: 0,
136
147
  tokensSaved: 0,
148
+ compactionTokensSaved: 0,
149
+ artifactRetrievals: 0,
137
150
  };
138
151
  this.toolRecords.set(toolName, record);
139
152
  }
@@ -1,6 +1,7 @@
1
1
  // Planu — Web Fetcher: consultDocs orchestration
2
2
  import { searchAndRegisterFramework, saveRegistry } from '../registry-updater.js';
3
3
  import { loadDocsRegistry, findDocsEntry, setDocsRegistryCache, getStoredRegistryPath, } from './registry-loader.js';
4
+ import { DEFAULT_CONFIG } from './cache.js';
4
5
  import { fetchUrl } from './http-client.js';
5
6
  import { extractTextContent, extractCodeBlocks, extractBestPractices, buildSummaryFromContent, buildPlaceholderSummary, buildPlaceholderExamples, buildPlaceholderBestPractices, } from './content-extractor.js';
6
7
  export async function consultDocsImpl(topic, framework) {
@@ -25,7 +26,10 @@ export async function consultDocsImpl(topic, framework) {
25
26
  : `https://www.google.com/search?q=${encodeURIComponent(`${resolvedFramework} ${topic} documentation`)}`;
26
27
  const baseUrl = docsEntry?.base ?? '';
27
28
  if (baseUrl) {
28
- const fetchResult = await fetchUrl(docsUrl);
29
+ const fetchResult = await fetchUrl(docsUrl, {
30
+ ...DEFAULT_CONFIG,
31
+ timeoutMs: 2_500,
32
+ });
29
33
  if (fetchResult) {
30
34
  const textContent = extractTextContent(fetchResult.content);
31
35
  const codeBlocks = extractCodeBlocks(fetchResult.content);
@@ -268,7 +268,10 @@ function safeWithTelemetry(toolName, handler) {
268
268
  // SPEC-455: Compress verbose JSON outputs to save LLM tokens
269
269
  const compressed = compressToolOutput(result);
270
270
  // SPEC-922: Apply compact mode middleware
271
- const compacted = applyCompactMode(compressed, decision);
271
+ const compacted = applyCompactMode(compressed, decision, {
272
+ projectPath,
273
+ flow: toolName,
274
+ });
272
275
  // Inject pending drift banner (non-blocking, informational only)
273
276
  const driftBanner = projectPath !== undefined ? await checkPendingDriftBanner(projectPath) : null;
274
277
  const withDrift = injectDriftBanner(compacted, driftBanner);
@@ -27,10 +27,12 @@ export function handleTokenUsage(args) {
27
27
  let details;
28
28
  if (groupBy === 'tool' && toolRecords.length > 0) {
29
29
  const lines = toolRecords.map((r) => `| ${r.toolName} | ${String(r.totalTokens)} | ${String(r.callCount)} | ` +
30
- `${String(r.cacheHits)}/${String(r.cacheHits + r.cacheMisses)} | ${String(r.tokensSaved)} |`);
30
+ `${String(r.cacheHits)}/${String(r.cacheHits + r.cacheMisses)} | ` +
31
+ `${String(r.tokensSaved)} | ${String(r.compactionTokensSaved ?? 0)} | ` +
32
+ `${String(r.artifactRetrievals ?? 0)} |`);
31
33
  details = [
32
- '| Tool | Tokens | Calls | Cache H/T | Saved |',
33
- '|------|--------|-------|-----------|-------|',
34
+ '| Tool | Tokens | Calls | Cache H/T | Saved | Compaction Saved | Retrievals |',
35
+ '|------|--------|-------|-----------|-------|------------------|------------|',
34
36
  ...lines,
35
37
  ].join('\n');
36
38
  }
@@ -20,6 +20,11 @@ export interface CompactDecision {
20
20
  contextUsed?: number;
21
21
  reason: 'header_above_enter' | 'header_below_exit' | 'hysteresis_keep' | 'no_header';
22
22
  }
23
+ export interface CompactModeOptions {
24
+ projectPath?: string;
25
+ sourcePath?: string;
26
+ flow?: string;
27
+ }
23
28
  export interface CompactMetrics {
24
29
  compactDecisions: number;
25
30
  verboseDecisions: number;
@@ -0,0 +1,96 @@
1
+ export type ContextArtifactContentType = 'json' | 'test-log' | 'runtime-log' | 'search-results' | 'code' | 'spec-or-handoff' | 'generic-text';
2
+ export interface ContextArtifactCompactionMetadata {
3
+ artifactRef: string;
4
+ originalTokens: number;
5
+ compactTokens: number;
6
+ tokensSaved: number;
7
+ strategy: string;
8
+ contentType: ContextArtifactContentType;
9
+ expiresAt: string;
10
+ retrievalHint: string;
11
+ }
12
+ export interface ContextArtifact {
13
+ ref: string;
14
+ contentHash: string;
15
+ createdAt: string;
16
+ expiresAt: string;
17
+ ttlMs: number;
18
+ projectId: string;
19
+ contentType: ContextArtifactContentType;
20
+ strategy: string;
21
+ originalTokens: number;
22
+ compactTokens: number;
23
+ tokensSaved: number;
24
+ retrievalCount: number;
25
+ metadata: Record<string, string | number | boolean>;
26
+ originalContent: string;
27
+ compactContent: string;
28
+ }
29
+ export interface StoreContextArtifactInput {
30
+ projectPath: string;
31
+ originalContent: string;
32
+ compactContent: string;
33
+ contentType: ContextArtifactContentType;
34
+ strategy: string;
35
+ originalTokens: number;
36
+ compactTokens: number;
37
+ ttlMs: number;
38
+ sourcePath?: string;
39
+ flow?: string;
40
+ metadata?: Record<string, unknown>;
41
+ }
42
+ export interface StoreContextArtifactResult {
43
+ stored: boolean;
44
+ artifact?: ContextArtifact;
45
+ metadata?: ContextArtifactCompactionMetadata;
46
+ refusedReason?: string;
47
+ }
48
+ export interface RetrieveContextArtifactResult {
49
+ found: boolean;
50
+ artifact?: ContextArtifact;
51
+ reason?: 'missing' | 'expired' | 'corrupt' | 'invalid-ref' | 'unauthorized';
52
+ hint: string;
53
+ }
54
+ export interface ContextArtifactStats {
55
+ artifactCount: number;
56
+ totalOriginalTokens: number;
57
+ totalCompactTokens: number;
58
+ totalTokensSaved: number;
59
+ retrievalCount: number;
60
+ }
61
+ export interface ContentAwareCompactionInput {
62
+ text: string;
63
+ policy: {
64
+ contextArtifacts?: {
65
+ enabled?: boolean;
66
+ ttlMs?: number;
67
+ minTokens?: number;
68
+ };
69
+ contentCompaction?: {
70
+ strategies?: Partial<Record<ContextArtifactContentType, {
71
+ maxLines?: number;
72
+ maxSnippetChars?: number;
73
+ }>>;
74
+ };
75
+ redaction: {
76
+ maxSnippetChars: number;
77
+ redactPatterns: string[];
78
+ };
79
+ };
80
+ projectPath?: string;
81
+ kind?: string;
82
+ sourcePath?: string;
83
+ flow?: string;
84
+ metadata?: Record<string, unknown>;
85
+ }
86
+ export interface ContentAwareCompactionResult {
87
+ text: string;
88
+ originalTokens: number;
89
+ compactTokens: number;
90
+ tokensSaved: number;
91
+ strategy: string;
92
+ contentType: ContextArtifactContentType;
93
+ artifact?: ContextArtifactCompactionMetadata;
94
+ refusedReason?: string;
95
+ }
96
+ //# sourceMappingURL=context-artifacts.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context-artifacts.js.map
@@ -80,6 +80,7 @@ export * from './workers.js';
80
80
  export * from './orchestration-runtime.js';
81
81
  export * from './token-optimization.js';
82
82
  export * from './token-waste-autopilot.js';
83
+ export * from './context-artifacts.js';
83
84
  export * from './minimal-implementation-gate.js';
84
85
  export * from './llm-providers.js';
85
86
  export * from './plugins.js';
@@ -81,6 +81,7 @@ export * from './workers.js';
81
81
  export * from './orchestration-runtime.js';
82
82
  export * from './token-optimization.js';
83
83
  export * from './token-waste-autopilot.js';
84
+ export * from './context-artifacts.js';
84
85
  export * from './minimal-implementation-gate.js';
85
86
  export * from './llm-providers.js';
86
87
  export * from './plugins.js';
@@ -104,6 +104,8 @@ export interface ToolTokenRecord {
104
104
  cacheHits: number;
105
105
  cacheMisses: number;
106
106
  tokensSaved: number;
107
+ compactionTokensSaved?: number;
108
+ artifactRetrievals?: number;
107
109
  }
108
110
  export interface BatchEntry {
109
111
  input: unknown;
@@ -1,3 +1,4 @@
1
+ import type { ContextArtifactCompactionMetadata } from './context-artifacts.js';
1
2
  export type TokenWasteDecisionKind = 'include' | 'summarize' | 'exclude' | 'recommend' | 'avoid' | 'warn' | 'override';
2
3
  export type TokenWasteConfidence = 'high' | 'medium' | 'low';
3
4
  export type TokenWasteAction = string;
@@ -21,6 +22,7 @@ export interface TokenWasteOutputStrategy {
21
22
  keepFailures?: boolean;
22
23
  uniqueOnly?: boolean;
23
24
  summarizeKeys?: boolean;
25
+ maxSnippetChars?: number;
24
26
  }
25
27
  export interface TokenWastePolicy {
26
28
  version: 1;
@@ -35,6 +37,14 @@ export interface TokenWastePolicy {
35
37
  outputs: {
36
38
  strategies: Record<string, TokenWasteOutputStrategy>;
37
39
  };
40
+ contextArtifacts?: {
41
+ enabled: boolean;
42
+ ttlMs: number;
43
+ minTokens: number;
44
+ };
45
+ contentCompaction?: {
46
+ strategies: Record<string, TokenWasteOutputStrategy>;
47
+ };
38
48
  tools: {
39
49
  groups: Record<string, string[]>;
40
50
  maxRecommended: number;
@@ -83,12 +93,17 @@ export interface VerboseOutputInput {
83
93
  text: string;
84
94
  policy: TokenWastePolicy;
85
95
  fullOutputRef?: string;
96
+ projectPath?: string;
97
+ sourcePath?: string;
98
+ flow?: string;
99
+ metadata?: Record<string, unknown>;
86
100
  }
87
101
  export interface VerboseOutputResult {
88
102
  text: string;
89
103
  originalLines: number;
90
104
  returnedLines: number;
91
105
  fullOutputRef?: string;
106
+ compaction?: ContextArtifactCompactionMetadata;
92
107
  decisions: TokenWasteDecision[];
93
108
  }
94
109
  export interface ToolRelevanceInput {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.7.2",
3
+ "version": "4.7.3",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.7.2",
38
- "@planu/core-darwin-x64": "4.7.2",
39
- "@planu/core-linux-arm64-gnu": "4.7.2",
40
- "@planu/core-linux-arm64-musl": "4.7.2",
41
- "@planu/core-linux-x64-gnu": "4.7.2",
42
- "@planu/core-linux-x64-musl": "4.7.2",
43
- "@planu/core-win32-arm64-msvc": "4.7.2",
44
- "@planu/core-win32-x64-msvc": "4.7.2"
37
+ "@planu/core-darwin-arm64": "4.7.3",
38
+ "@planu/core-darwin-x64": "4.7.3",
39
+ "@planu/core-linux-arm64-gnu": "4.7.3",
40
+ "@planu/core-linux-arm64-musl": "4.7.3",
41
+ "@planu/core-linux-x64-gnu": "4.7.3",
42
+ "@planu/core-linux-x64-musl": "4.7.3",
43
+ "@planu/core-win32-arm64-msvc": "4.7.3",
44
+ "@planu/core-win32-x64-msvc": "4.7.3"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
@@ -129,7 +129,7 @@
129
129
  ],
130
130
  "license": "SEE LICENSE IN LICENSE",
131
131
  "dependencies": {
132
- "@anthropic-ai/sdk": "^0.104.2",
132
+ "@anthropic-ai/sdk": "^0.105.0",
133
133
  "@modelcontextprotocol/sdk": "^1.29.0",
134
134
  "glob": "^13.0.6",
135
135
  "yaml": "^2.9.0",
@@ -191,7 +191,7 @@
191
191
  "eslint-config-prettier": "^10.1.8",
192
192
  "eslint-import-resolver-typescript": "^4.4.5",
193
193
  "eslint-plugin-import": "^2.32.0",
194
- "happy-dom": "^20.10.5",
194
+ "happy-dom": "^20.10.6",
195
195
  "husky": "^9.1.7",
196
196
  "javascript-obfuscator": "^5.4.3",
197
197
  "knip": "^6.17.1",
package/planu-native.json CHANGED
@@ -1,20 +1,26 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.7.2",
4
+ "version": "4.7.3",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
8
8
  "requiresMcp": false,
9
9
  "requiresDaemon": false,
10
- "hosts": ["codex", "claude-code"],
10
+ "hosts": [
11
+ "codex",
12
+ "claude-code"
13
+ ],
11
14
  "commands": [
12
15
  {
13
16
  "id": "planu.status",
14
17
  "title": "Project status",
15
18
  "description": "Show the compact Planu project snapshot without loading the MCP tool graph.",
16
19
  "invocation": "planu status",
17
- "hosts": ["codex", "claude-code"],
20
+ "hosts": [
21
+ "codex",
22
+ "claude-code"
23
+ ],
18
24
  "requiresMcp": false,
19
25
  "requiresDaemon": false,
20
26
  "mapsTo": "handlePlanStatus"
@@ -24,7 +30,10 @@
24
30
  "title": "Create spec",
25
31
  "description": "Create a new spec through the CLI-backed SDD contract.",
26
32
  "invocation": "planu spec create \"<title>\"",
27
- "hosts": ["codex", "claude-code"],
33
+ "hosts": [
34
+ "codex",
35
+ "claude-code"
36
+ ],
28
37
  "requiresMcp": false,
29
38
  "requiresDaemon": false,
30
39
  "mapsTo": "handleCreateSpec"
@@ -34,7 +43,10 @@
34
43
  "title": "List specs",
35
44
  "description": "List specs in the current project with optional status/type filters.",
36
45
  "invocation": "planu spec list",
37
- "hosts": ["codex", "claude-code"],
46
+ "hosts": [
47
+ "codex",
48
+ "claude-code"
49
+ ],
38
50
  "requiresMcp": false,
39
51
  "requiresDaemon": false,
40
52
  "mapsTo": "handleListSpecs"
@@ -44,7 +56,10 @@
44
56
  "title": "Validate spec",
45
57
  "description": "Validate a spec against the current codebase from the native CLI surface.",
46
58
  "invocation": "planu spec validate SPEC-001",
47
- "hosts": ["codex", "claude-code"],
59
+ "hosts": [
60
+ "codex",
61
+ "claude-code"
62
+ ],
48
63
  "requiresMcp": false,
49
64
  "requiresDaemon": false,
50
65
  "mapsTo": "handleValidate"
@@ -54,7 +69,10 @@
54
69
  "title": "Audit technical debt",
55
70
  "description": "Run the read-only project audit path for lightweight debt checks.",
56
71
  "invocation": "planu audit debt",
57
- "hosts": ["codex", "claude-code"],
72
+ "hosts": [
73
+ "codex",
74
+ "claude-code"
75
+ ],
58
76
  "requiresMcp": false,
59
77
  "requiresDaemon": false,
60
78
  "mapsTo": "handleAudit"
@@ -64,7 +82,10 @@
64
82
  "title": "Check release readiness",
65
83
  "description": "Check local branch cleanliness and main/develop/release sync readiness.",
66
84
  "invocation": "planu release check",
67
- "hosts": ["codex", "claude-code"],
85
+ "hosts": [
86
+ "codex",
87
+ "claude-code"
88
+ ],
68
89
  "requiresMcp": false,
69
90
  "requiresDaemon": false,
70
91
  "mapsTo": "releaseCommand"
package/planu-plugin.json CHANGED
@@ -2,9 +2,12 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "4.7.2",
5
+ "version": "4.7.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
- "command": ["npx", "@planu/cli@latest"],
7
+ "command": [
8
+ "npx",
9
+ "@planu/cli@latest"
10
+ ],
8
11
  "packageName": "@planu/cli",
9
12
  "capabilities": {
10
13
  "tools": [
@@ -23,17 +26,42 @@
23
26
  "create_skill",
24
27
  "skill_search"
25
28
  ],
26
- "resources": ["planu://specs/list", "planu://specs/{id}", "planu://project/status", "planu://roadmap"],
27
- "prompts": ["create-spec-from-idea", "review-spec-readiness", "generate-implementation-plan"],
28
- "subagents": ["sdd-orchestrator", "spec-challenger", "test-generator"]
29
+ "resources": [
30
+ "planu://specs/list",
31
+ "planu://specs/{id}",
32
+ "planu://project/status",
33
+ "planu://roadmap"
34
+ ],
35
+ "prompts": [
36
+ "create-spec-from-idea",
37
+ "review-spec-readiness",
38
+ "generate-implementation-plan"
39
+ ],
40
+ "subagents": [
41
+ "sdd-orchestrator",
42
+ "spec-challenger",
43
+ "test-generator"
44
+ ]
29
45
  },
30
46
  "compatibility": {
31
47
  "minimumHostVersion": "1.0.0",
32
- "requiredFeatures": ["mcp-tools", "file-editing"]
48
+ "requiredFeatures": [
49
+ "mcp-tools",
50
+ "file-editing"
51
+ ]
33
52
  },
34
53
  "repository": "https://github.com/planu-dev/planu",
35
54
  "author": "Planu",
36
55
  "license": "MIT",
37
56
  "homepage": "https://planu.dev",
38
- "keywords": ["sdd", "spec-driven-development", "mcp", "specs", "planning", "ai", "bdd", "tdd"]
57
+ "keywords": [
58
+ "sdd",
59
+ "spec-driven-development",
60
+ "mcp",
61
+ "specs",
62
+ "planning",
63
+ "ai",
64
+ "bdd",
65
+ "tdd"
66
+ ]
39
67
  }