pan-wizard 2.9.1 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -9
- package/agents/pan-conductor.md +189 -0
- package/agents/pan-counterfactual.md +112 -0
- package/agents/pan-debugger.md +15 -1
- package/agents/pan-distiller.md +82 -0
- package/agents/pan-document_code.md +21 -0
- package/agents/pan-executor.md +16 -0
- package/agents/pan-hardener.md +113 -0
- package/agents/pan-integration-checker.md +2 -0
- package/agents/pan-knowledge.md +81 -0
- package/agents/pan-meta-reviewer.md +91 -0
- package/agents/pan-optimizer.md +242 -0
- package/agents/pan-plan-checker.md +2 -0
- package/agents/pan-previewer.md +98 -0
- package/agents/pan-project-researcher.md +4 -4
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +2 -0
- package/bin/install-lib.cjs +197 -0
- package/bin/install.js +2048 -1959
- package/commands/pan/cost.md +132 -0
- package/commands/pan/exec-phase.md +15 -0
- package/commands/pan/focus-auto.md +168 -3
- package/commands/pan/focus-exec.md +21 -1
- package/commands/pan/focus-scan.md +6 -0
- package/commands/pan/git.md +223 -0
- package/commands/pan/knowledge.md +129 -0
- package/commands/pan/learn.md +61 -0
- package/commands/pan/map-codebase.md +15 -0
- package/commands/pan/mcp-bridge.md +145 -0
- package/commands/pan/milestone-done.md +9 -0
- package/commands/pan/optimize.md +86 -0
- package/commands/pan/plan-phase.md +11 -0
- package/commands/pan/preview.md +114 -0
- package/commands/pan/profile.md +37 -0
- package/commands/pan/review-deep.md +128 -0
- package/commands/pan/verify-phase.md +11 -0
- package/commands/pan/what-if.md +146 -0
- package/hooks/dist/pan-cost-logger.js +102 -0
- package/hooks/dist/pan-statusline.js +154 -108
- package/hooks/dist/pan-trace-logger.js +197 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
- package/pan-wizard-core/bin/lib/bus.cjs +251 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1 -0
- package/pan-wizard-core/bin/lib/constants.cjs +44 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
- package/pan-wizard-core/bin/lib/core.cjs +91 -6
- package/pan-wizard-core/bin/lib/cost.cjs +359 -0
- package/pan-wizard-core/bin/lib/distill.cjs +510 -0
- package/pan-wizard-core/bin/lib/focus.cjs +108 -3
- package/pan-wizard-core/bin/lib/git.cjs +407 -0
- package/pan-wizard-core/bin/lib/init.cjs +5 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
- package/pan-wizard-core/bin/lib/memory.cjs +252 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
- package/pan-wizard-core/bin/lib/phase.cjs +40 -13
- package/pan-wizard-core/bin/lib/preview.cjs +480 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
- package/pan-wizard-core/bin/lib/state.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify.cjs +34 -1
- package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
- package/pan-wizard-core/bin/pan-tools.cjs +317 -4
- package/pan-wizard-core/templates/playbook.md +53 -0
- package/pan-wizard-core/templates/preview-report.md +93 -0
- package/pan-wizard-core/templates/roadmap.md +24 -24
- package/pan-wizard-core/templates/state.md +12 -9
- package/pan-wizard-core/workflows/exec-phase.md +97 -0
- package/pan-wizard-core/workflows/learn.md +91 -0
- package/pan-wizard-core/workflows/optimize.md +139 -0
- package/pan-wizard-core/workflows/plan-phase.md +28 -1
- package/pan-wizard-core/workflows/quick.md +7 -0
- package/pan-wizard-core/workflows/verify-phase.md +16 -0
- package/scripts/build-hooks.js +3 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge — MCP tool discovery + per-phase recommendation (Spec B v2 Y-5, v3.3).
|
|
3
|
+
*
|
|
4
|
+
* Discovery-only scope. We discover what MCP tools are reachable from the
|
|
5
|
+
* host runtime and what their shapes look like, then recommend which tools
|
|
6
|
+
* a given phase plan might use. We do NOT auto-inject tools into plans or
|
|
7
|
+
* auto-invoke them — those belong to a future wave once MCP schemas stabilize.
|
|
8
|
+
*
|
|
9
|
+
* Data lives at `.planning/bridge/available-tools.json`:
|
|
10
|
+
* {
|
|
11
|
+
* cached_at: "2026-04-18T...",
|
|
12
|
+
* runtime: "claude",
|
|
13
|
+
* servers: [
|
|
14
|
+
* {
|
|
15
|
+
* name: "linear",
|
|
16
|
+
* version: "1.2.3",
|
|
17
|
+
* tools: [
|
|
18
|
+
* { name: "linear.updateTicket", description: "...", schema: {...} },
|
|
19
|
+
* ...
|
|
20
|
+
* ]
|
|
21
|
+
* },
|
|
22
|
+
* ...
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Populating this file is the host runtime's responsibility (Claude Code's
|
|
27
|
+
* MCP list API, etc.). This module reads the cache and reasons over it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { output, error, safeReadFile, toPosix, findPhaseInternal } = require('./core.cjs');
|
|
33
|
+
const { PLANNING_DIR } = require('./constants.cjs');
|
|
34
|
+
const { planningPath } = require('./utils.cjs');
|
|
35
|
+
|
|
36
|
+
const BRIDGE_DIR = 'bridge';
|
|
37
|
+
const TOOLS_FILE = 'available-tools.json';
|
|
38
|
+
|
|
39
|
+
function bridgeDir(cwd) {
|
|
40
|
+
return path.join(planningPath(cwd), BRIDGE_DIR);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toolsCacheFile(cwd) {
|
|
44
|
+
return path.join(bridgeDir(cwd), TOOLS_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load the cached tool list. Returns an empty catalog if the cache is
|
|
49
|
+
* missing or malformed.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} cwd - Project root
|
|
52
|
+
* @returns {{cached_at: string|null, runtime: string|null, servers: Array, source: 'cache'|'empty'}}
|
|
53
|
+
*/
|
|
54
|
+
function loadToolCache(cwd) {
|
|
55
|
+
const raw = safeReadFile(toolsCacheFile(cwd));
|
|
56
|
+
if (!raw) return { cached_at: null, runtime: null, servers: [], source: 'empty' };
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
return {
|
|
60
|
+
cached_at: parsed.cached_at || null,
|
|
61
|
+
runtime: parsed.runtime || null,
|
|
62
|
+
servers: Array.isArray(parsed.servers) ? parsed.servers : [],
|
|
63
|
+
source: 'cache',
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return { cached_at: null, runtime: null, servers: [], source: 'empty' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Write the tool cache. Used by the command when the host runtime provides
|
|
72
|
+
* a fresh tool list (or by tests for fixture setup).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} cwd - Project root
|
|
75
|
+
* @param {{runtime, servers}} data
|
|
76
|
+
* @returns {{written: true, file: string}|{error: string}}
|
|
77
|
+
*/
|
|
78
|
+
function writeToolCache(cwd, data) {
|
|
79
|
+
if (!data || typeof data !== 'object') return { error: 'data required' };
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(bridgeDir(cwd), { recursive: true });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { error: `Failed to create bridge dir: ${e.message}` };
|
|
84
|
+
}
|
|
85
|
+
const payload = {
|
|
86
|
+
cached_at: new Date().toISOString(),
|
|
87
|
+
runtime: data.runtime || null,
|
|
88
|
+
servers: Array.isArray(data.servers) ? data.servers : [],
|
|
89
|
+
};
|
|
90
|
+
const file = toolsCacheFile(cwd);
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8');
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { error: `Failed to write ${file}: ${e.message}` };
|
|
95
|
+
}
|
|
96
|
+
return { written: true, file: toPosix(path.relative(cwd, file)) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Flatten server → tool hierarchy into a single list for easier reasoning.
|
|
101
|
+
*
|
|
102
|
+
* @param {Array} servers - From loadToolCache().servers
|
|
103
|
+
* @returns {Array<{server, name, description, schema}>}
|
|
104
|
+
*/
|
|
105
|
+
function flattenTools(servers) {
|
|
106
|
+
const tools = [];
|
|
107
|
+
for (const server of servers || []) {
|
|
108
|
+
if (!server || !Array.isArray(server.tools)) continue;
|
|
109
|
+
for (const tool of server.tools) {
|
|
110
|
+
if (!tool || !tool.name) continue;
|
|
111
|
+
tools.push({
|
|
112
|
+
server: server.name,
|
|
113
|
+
name: tool.name,
|
|
114
|
+
description: tool.description || '',
|
|
115
|
+
schema: tool.schema || null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return tools;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List all available MCP tools, flattened.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} cwd - Project root
|
|
126
|
+
* @returns {Object}
|
|
127
|
+
*/
|
|
128
|
+
function listTools(cwd) {
|
|
129
|
+
const cache = loadToolCache(cwd);
|
|
130
|
+
const tools = flattenTools(cache.servers);
|
|
131
|
+
return {
|
|
132
|
+
cached_at: cache.cached_at,
|
|
133
|
+
runtime: cache.runtime,
|
|
134
|
+
server_count: cache.servers.length,
|
|
135
|
+
tool_count: tools.length,
|
|
136
|
+
tools,
|
|
137
|
+
source: cache.source,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Recommendation scoring ────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Score a tool's relevance to a phase by matching plan keywords against
|
|
145
|
+
* tool name + description. Naive frequency-based scoring (no embeddings).
|
|
146
|
+
*
|
|
147
|
+
* @param {string} phaseText - Combined plan text
|
|
148
|
+
* @param {{server, name, description}} tool
|
|
149
|
+
* @returns {{score: number, hits: Array<string>}}
|
|
150
|
+
*/
|
|
151
|
+
function scoreToolForPhase(phaseText, tool) {
|
|
152
|
+
if (!phaseText || !tool) return { score: 0, hits: [] };
|
|
153
|
+
const body = phaseText.toLowerCase();
|
|
154
|
+
const haystack = `${tool.server || ''} ${tool.name || ''} ${tool.description || ''}`.toLowerCase();
|
|
155
|
+
|
|
156
|
+
// Extract keywords from the tool's identity: split on non-word chars, dedupe, keep ≥3 chars.
|
|
157
|
+
const keywords = [...new Set(haystack.split(/\W+/).filter(w => w.length >= 3))];
|
|
158
|
+
|
|
159
|
+
let score = 0;
|
|
160
|
+
const hits = [];
|
|
161
|
+
for (const kw of keywords) {
|
|
162
|
+
const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
163
|
+
const count = (body.match(re) || []).length;
|
|
164
|
+
if (count > 0) {
|
|
165
|
+
score += count;
|
|
166
|
+
hits.push(kw);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { score, hits };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Recommend MCP tools for a specific phase based on plan text keyword match.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} cwd - Project root
|
|
176
|
+
* @param {string|number} phaseNum - Phase identifier
|
|
177
|
+
* @param {Object} [opts] - {max_recommendations, min_score}
|
|
178
|
+
* @returns {Object}
|
|
179
|
+
*/
|
|
180
|
+
function recommendForPhase(cwd, phaseNum, opts) {
|
|
181
|
+
const cache = loadToolCache(cwd);
|
|
182
|
+
const tools = flattenTools(cache.servers);
|
|
183
|
+
if (tools.length === 0) {
|
|
184
|
+
return {
|
|
185
|
+
phase: String(phaseNum),
|
|
186
|
+
runtime: cache.runtime,
|
|
187
|
+
recommendations: [],
|
|
188
|
+
reason: 'no MCP tools cached — run `pan-tools bridge cache` or ensure host runtime populates .planning/bridge/available-tools.json',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
193
|
+
if (!phaseInfo || !phaseInfo.found) {
|
|
194
|
+
return {
|
|
195
|
+
phase: String(phaseNum),
|
|
196
|
+
error: `Phase ${phaseNum} not found in .planning/phases/`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const phaseDir = path.join(cwd, phaseInfo.directory);
|
|
201
|
+
const planTexts = (phaseInfo.plans || [])
|
|
202
|
+
.map(f => safeReadFile(path.join(phaseDir, f)) || '')
|
|
203
|
+
.join('\n');
|
|
204
|
+
|
|
205
|
+
const max = Math.max(1, Math.min(50, Number(opts?.max_recommendations) || 10));
|
|
206
|
+
const minScore = Math.max(0, Number(opts?.min_score) || 1);
|
|
207
|
+
|
|
208
|
+
const scored = tools
|
|
209
|
+
.map(tool => ({
|
|
210
|
+
...tool,
|
|
211
|
+
...scoreToolForPhase(planTexts, tool),
|
|
212
|
+
}))
|
|
213
|
+
.filter(t => t.score >= minScore)
|
|
214
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
215
|
+
.slice(0, max);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
phase: String(phaseNum),
|
|
219
|
+
phase_name: phaseInfo.name || null,
|
|
220
|
+
runtime: cache.runtime,
|
|
221
|
+
total_candidates: tools.length,
|
|
222
|
+
recommendations: scored.map(t => ({
|
|
223
|
+
server: t.server,
|
|
224
|
+
name: t.name,
|
|
225
|
+
description: t.description,
|
|
226
|
+
score: t.score,
|
|
227
|
+
hits: t.hits,
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── CLI wrappers ───────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function cmdBridgeList(cwd, raw) {
|
|
235
|
+
output(listTools(cwd), raw);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cmdBridgeRecommend(cwd, phaseNum, opts, raw) {
|
|
239
|
+
if (!phaseNum) error('Usage: bridge recommend <phase> [--max N] [--min-score N]');
|
|
240
|
+
output(recommendForPhase(cwd, phaseNum, opts), raw);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cmdBridgeCache(cwd, serversJson, runtime, raw) {
|
|
244
|
+
// For scripted cache writes. Normally the host runtime writes the file,
|
|
245
|
+
// but this CLI path lets users seed it for testing or from external scripts.
|
|
246
|
+
if (!serversJson) {
|
|
247
|
+
// No payload — just echo the current cache path/state.
|
|
248
|
+
output(listTools(cwd), raw);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
let servers;
|
|
252
|
+
try { servers = JSON.parse(serversJson); }
|
|
253
|
+
catch (e) { error(`Invalid --servers JSON: ${e.message}`); }
|
|
254
|
+
output(writeToolCache(cwd, { runtime, servers }), raw);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
loadToolCache,
|
|
259
|
+
writeToolCache,
|
|
260
|
+
flattenTools,
|
|
261
|
+
listTools,
|
|
262
|
+
scoreToolForPhase,
|
|
263
|
+
recommendForPhase,
|
|
264
|
+
cmdBridgeList,
|
|
265
|
+
cmdBridgeRecommend,
|
|
266
|
+
cmdBridgeCache,
|
|
267
|
+
BRIDGE_DIR,
|
|
268
|
+
TOOLS_FILE,
|
|
269
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bus — file-backed message channels for agent-to-agent communication
|
|
3
|
+
*
|
|
4
|
+
* Part of Spec B v2 Y-7 infrastructure (v3.0). Enables future hierarchical
|
|
5
|
+
* agent spawning (exec-phase --hierarchical, Wave 5) and inter-agent
|
|
6
|
+
* coordination (review-deep, Wave 3) without committing to an in-process
|
|
7
|
+
* IPC mechanism.
|
|
8
|
+
*
|
|
9
|
+
* Storage model:
|
|
10
|
+
* .planning/bus/<channel>.jsonl — append-only JSON Lines
|
|
11
|
+
* Each line: {ts, source, payload}
|
|
12
|
+
*
|
|
13
|
+
* Channels are created on first publish. Readers use cursor-based drain
|
|
14
|
+
* (read N lines from an offset) or consume-all drain (read + truncate).
|
|
15
|
+
*
|
|
16
|
+
* Concurrent-write safety: each publish opens the file with append flag
|
|
17
|
+
* (`a`) which the OS treats atomically for writes <PIPE_BUF on POSIX and
|
|
18
|
+
* sub-buffer writes on Windows. Entries are single lines. For parallel
|
|
19
|
+
* publishers writing large payloads, see the safety note below.
|
|
20
|
+
*
|
|
21
|
+
* Agent-name / channel-name validation: restricted to
|
|
22
|
+
* `^[a-zA-Z0-9_-]+$` to block path traversal.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { output, error } = require('./core.cjs');
|
|
28
|
+
const { PLANNING_DIR } = require('./constants.cjs');
|
|
29
|
+
const { planningPath } = require('./utils.cjs');
|
|
30
|
+
|
|
31
|
+
const BUS_DIR = 'bus';
|
|
32
|
+
const NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
33
|
+
const DEFAULT_DRAIN_LIMIT = 1000;
|
|
34
|
+
|
|
35
|
+
function busDir(cwd) {
|
|
36
|
+
return path.join(planningPath(cwd), BUS_DIR);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function channelFile(cwd, channel) {
|
|
40
|
+
return path.join(busDir(cwd), `${channel}.jsonl`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateName(name, label) {
|
|
44
|
+
if (typeof name !== 'string' || !NAME_RE.test(name)) {
|
|
45
|
+
return `Invalid ${label}: ${name}. Must match ${NAME_RE}`;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nowIso() {
|
|
51
|
+
return new Date().toISOString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Publish a message to a channel. Creates channel file + dir if missing.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} cwd - Project root
|
|
58
|
+
* @param {string} channel - Channel name (validated)
|
|
59
|
+
* @param {*} payload - JSON-serializable payload
|
|
60
|
+
* @param {Object} [opts] - {source: string} — who sent it (agent name, command name)
|
|
61
|
+
* @returns {{published: true, ts: string, file: string, size: number}|{error: string}}
|
|
62
|
+
*/
|
|
63
|
+
function publish(cwd, channel, payload, opts) {
|
|
64
|
+
const chErr = validateName(channel, 'channel');
|
|
65
|
+
if (chErr) return { error: chErr };
|
|
66
|
+
const source = opts?.source;
|
|
67
|
+
// Treat null + undefined + empty string as "no source provided".
|
|
68
|
+
if (source !== undefined && source !== null && source !== '') {
|
|
69
|
+
const sErr = validateName(source, 'source');
|
|
70
|
+
if (sErr) return { error: sErr };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const normalizedSource = (source !== undefined && source !== null && source !== '') ? source : null;
|
|
74
|
+
let line;
|
|
75
|
+
try {
|
|
76
|
+
line = JSON.stringify({ ts: nowIso(), source: normalizedSource, payload }) + '\n';
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { error: `payload not JSON-serializable: ${e.message}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(busDir(cwd), { recursive: true });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return { error: `Failed to create bus dir: ${e.message}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const file = channelFile(cwd, channel);
|
|
88
|
+
try {
|
|
89
|
+
fs.appendFileSync(file, line, { encoding: 'utf-8' });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { error: `Failed to append to channel ${channel}: ${e.message}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let size = 0;
|
|
95
|
+
try { size = fs.statSync(file).size; } catch { /* race — ignore */ }
|
|
96
|
+
|
|
97
|
+
return { published: true, ts: JSON.parse(line).ts, file, size };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a channel file into an array of entries.
|
|
102
|
+
* @param {string} cwd - Project root
|
|
103
|
+
* @param {string} channel - Channel name
|
|
104
|
+
* @param {Object} [opts] - {offset: number, limit: number}
|
|
105
|
+
* @returns {{entries: Array, total: number, offset: number, more: boolean}|{error: string}}
|
|
106
|
+
*/
|
|
107
|
+
function readChannel(cwd, channel, opts) {
|
|
108
|
+
const chErr = validateName(channel, 'channel');
|
|
109
|
+
if (chErr) return { error: chErr };
|
|
110
|
+
const offset = Math.max(0, Number(opts?.offset) || 0);
|
|
111
|
+
const rawLimit = Number(opts?.limit);
|
|
112
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : DEFAULT_DRAIN_LIMIT;
|
|
113
|
+
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = fs.readFileSync(channelFile(cwd, channel), 'utf-8');
|
|
117
|
+
} catch {
|
|
118
|
+
return { entries: [], total: 0, offset: 0, more: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
122
|
+
const total = lines.length;
|
|
123
|
+
const slice = lines.slice(offset, offset + limit);
|
|
124
|
+
const entries = [];
|
|
125
|
+
for (let i = 0; i < slice.length; i++) {
|
|
126
|
+
try {
|
|
127
|
+
entries.push(JSON.parse(slice[i]));
|
|
128
|
+
} catch {
|
|
129
|
+
// Skip malformed lines but don't fail the whole read.
|
|
130
|
+
entries.push({ ts: null, source: null, payload: null, malformed: true, raw: slice[i] });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { entries, total, offset, more: offset + entries.length < total };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drain (read + optionally truncate) messages from a channel.
|
|
139
|
+
*
|
|
140
|
+
* Three drain modes:
|
|
141
|
+
* - `peek` (default): read entries, leave file untouched
|
|
142
|
+
* - `consume`: read entries, truncate file to zero bytes
|
|
143
|
+
* - `archive`: read entries, rename file to `<channel>-<ts>.archive.jsonl` so
|
|
144
|
+
* historical data is preserved while the channel restarts empty
|
|
145
|
+
*
|
|
146
|
+
* @param {string} cwd - Project root
|
|
147
|
+
* @param {string} channel - Channel name
|
|
148
|
+
* @param {Object} [opts] - {mode: 'peek'|'consume'|'archive', limit, offset}
|
|
149
|
+
* @returns {Object} Drain result
|
|
150
|
+
*/
|
|
151
|
+
function drain(cwd, channel, opts) {
|
|
152
|
+
const mode = opts?.mode || 'peek';
|
|
153
|
+
const read = readChannel(cwd, channel, opts);
|
|
154
|
+
if (read.error) return read;
|
|
155
|
+
|
|
156
|
+
if (mode === 'peek' || read.total === 0) return { ...read, mode };
|
|
157
|
+
|
|
158
|
+
const file = channelFile(cwd, channel);
|
|
159
|
+
if (mode === 'consume') {
|
|
160
|
+
try {
|
|
161
|
+
fs.writeFileSync(file, '', 'utf-8');
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { ...read, mode, drain_error: e.message };
|
|
164
|
+
}
|
|
165
|
+
} else if (mode === 'archive') {
|
|
166
|
+
const stamp = nowIso().replace(/[:.]/g, '-');
|
|
167
|
+
const archivePath = path.join(busDir(cwd), `${channel}-${stamp}.archive.jsonl`);
|
|
168
|
+
try {
|
|
169
|
+
fs.renameSync(file, archivePath);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { ...read, mode, drain_error: e.message };
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
return { error: `unknown drain mode: ${mode}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { ...read, mode };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* List channels + message counts + sizes for observability.
|
|
182
|
+
* @param {string} cwd - Project root
|
|
183
|
+
* @returns {{channels: Array<{channel: string, messages: number, bytes: number, archive: boolean}>}}
|
|
184
|
+
*/
|
|
185
|
+
function listChannels(cwd) {
|
|
186
|
+
let files;
|
|
187
|
+
try {
|
|
188
|
+
files = fs.readdirSync(busDir(cwd));
|
|
189
|
+
} catch {
|
|
190
|
+
return { channels: [] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const channels = [];
|
|
194
|
+
for (const f of files) {
|
|
195
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
196
|
+
const archive = f.includes('.archive.');
|
|
197
|
+
const nameBase = f.replace(/\.jsonl$/, '');
|
|
198
|
+
const channel = archive ? nameBase.replace(/\.archive$/, '') : nameBase;
|
|
199
|
+
const filePath = path.join(busDir(cwd), f);
|
|
200
|
+
let bytes = 0;
|
|
201
|
+
let messages = 0;
|
|
202
|
+
try {
|
|
203
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
204
|
+
bytes = Buffer.byteLength(content, 'utf-8');
|
|
205
|
+
messages = content.split('\n').filter(Boolean).length;
|
|
206
|
+
} catch { /* unreadable — skip */ }
|
|
207
|
+
channels.push({ channel, messages, bytes, archive });
|
|
208
|
+
}
|
|
209
|
+
channels.sort((a, b) => a.channel.localeCompare(b.channel) || (a.archive ? 1 : -1));
|
|
210
|
+
return { channels };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── CLI wrappers ───────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function cmdBusPublish(cwd, channel, rawPayload, opts, raw) {
|
|
216
|
+
if (!channel || rawPayload === undefined) {
|
|
217
|
+
error('Usage: bus publish <channel> <json-payload> [--source <name>]');
|
|
218
|
+
}
|
|
219
|
+
let payload;
|
|
220
|
+
try {
|
|
221
|
+
payload = JSON.parse(rawPayload);
|
|
222
|
+
} catch {
|
|
223
|
+
// Fall back: treat as plain string if not valid JSON.
|
|
224
|
+
payload = rawPayload;
|
|
225
|
+
}
|
|
226
|
+
const result = publish(cwd, channel, payload, opts);
|
|
227
|
+
output(result, raw);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cmdBusDrain(cwd, channel, opts, raw) {
|
|
231
|
+
if (!channel) error('Usage: bus drain <channel> [--mode peek|consume|archive] [--limit N] [--offset N]');
|
|
232
|
+
const result = drain(cwd, channel, opts);
|
|
233
|
+
output(result, raw);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cmdBusList(cwd, raw) {
|
|
237
|
+
output(listChannels(cwd), raw);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
publish,
|
|
242
|
+
readChannel,
|
|
243
|
+
drain,
|
|
244
|
+
listChannels,
|
|
245
|
+
validateName,
|
|
246
|
+
cmdBusPublish,
|
|
247
|
+
cmdBusDrain,
|
|
248
|
+
cmdBusList,
|
|
249
|
+
BUS_DIR,
|
|
250
|
+
DEFAULT_DRAIN_LIMIT,
|
|
251
|
+
};
|
|
@@ -721,6 +721,121 @@ function cmdBestPractices(cwd, raw) {
|
|
|
721
721
|
output(result, raw);
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
// ─── E-2: Repo token-size estimation (Opus 4.7 single-shot map-codebase) ────
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Estimate total token count of source files in a repository.
|
|
728
|
+
*
|
|
729
|
+
* Uses CHARS_PER_TOKEN as a rough approximation (no tokenizer shipped).
|
|
730
|
+
* Walks source files via walkSourceFiles (respects SKIP_DIRS) plus a curated
|
|
731
|
+
* set of "planning" files the map-codebase agent actually reads (top-level
|
|
732
|
+
* README, package.json, CLAUDE.md, docs/ top level).
|
|
733
|
+
*
|
|
734
|
+
* Returns enough info for map-codebase to choose between single-shot mode
|
|
735
|
+
* (all files fit in 1M context) and sharded mode (6-way parallel today).
|
|
736
|
+
*
|
|
737
|
+
* @param {string} cwd - Project root
|
|
738
|
+
* @param {Object} [opts]
|
|
739
|
+
* @param {number} [opts.threshold=700000] - Single-shot cutoff (tokens)
|
|
740
|
+
* @param {boolean} [opts.include_docs=true] - Whether to include docs/*.md top level
|
|
741
|
+
* @returns {{
|
|
742
|
+
* total_bytes: number,
|
|
743
|
+
* total_tokens: number,
|
|
744
|
+
* threshold: number,
|
|
745
|
+
* mode: 'single-shot'|'sharded',
|
|
746
|
+
* file_count: number,
|
|
747
|
+
* languages: Object<string, number>
|
|
748
|
+
* }}
|
|
749
|
+
*/
|
|
750
|
+
function estimateRepoTokenSize(cwd, opts) {
|
|
751
|
+
const { CHARS_PER_TOKEN } = require('./constants.cjs');
|
|
752
|
+
const threshold = (opts && typeof opts.threshold === 'number' && opts.threshold > 0)
|
|
753
|
+
? opts.threshold
|
|
754
|
+
: 700000;
|
|
755
|
+
const includeDocs = !opts || opts.include_docs !== false;
|
|
756
|
+
|
|
757
|
+
let totalBytes = 0;
|
|
758
|
+
let fileCount = 0;
|
|
759
|
+
const languages = {};
|
|
760
|
+
|
|
761
|
+
// Source files via the existing walker — respects gitignore-like skip list.
|
|
762
|
+
const walked = walkSourceFiles(cwd, cwd);
|
|
763
|
+
for (const [lang, files] of Object.entries(walked.files_by_language)) {
|
|
764
|
+
for (const relPath of files) {
|
|
765
|
+
const abs = path.join(cwd, relPath);
|
|
766
|
+
try {
|
|
767
|
+
const stat = fs.statSync(abs);
|
|
768
|
+
totalBytes += stat.size;
|
|
769
|
+
languages[lang] = (languages[lang] || 0) + stat.size;
|
|
770
|
+
fileCount += 1;
|
|
771
|
+
} catch { /* file may have been removed between walk and stat — ignore */ }
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Curated top-level planning files the agent actually reads.
|
|
776
|
+
const planningCandidates = [
|
|
777
|
+
'README.md',
|
|
778
|
+
'CLAUDE.md',
|
|
779
|
+
'AGENTS.md',
|
|
780
|
+
'package.json',
|
|
781
|
+
'pyproject.toml',
|
|
782
|
+
'go.mod',
|
|
783
|
+
'Cargo.toml',
|
|
784
|
+
];
|
|
785
|
+
for (const rel of planningCandidates) {
|
|
786
|
+
try {
|
|
787
|
+
const stat = fs.statSync(path.join(cwd, rel));
|
|
788
|
+
if (stat.isFile()) {
|
|
789
|
+
totalBytes += stat.size;
|
|
790
|
+
languages.docs = (languages.docs || 0) + stat.size;
|
|
791
|
+
fileCount += 1;
|
|
792
|
+
}
|
|
793
|
+
} catch { /* missing — expected */ }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// docs/*.md at top level only (avoid recursing into specs/decisions/archive).
|
|
797
|
+
if (includeDocs) {
|
|
798
|
+
const docsDir = path.join(cwd, 'docs');
|
|
799
|
+
try {
|
|
800
|
+
const entries = fs.readdirSync(docsDir, { withFileTypes: true });
|
|
801
|
+
for (const e of entries) {
|
|
802
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
803
|
+
try {
|
|
804
|
+
const stat = fs.statSync(path.join(docsDir, e.name));
|
|
805
|
+
totalBytes += stat.size;
|
|
806
|
+
languages.docs = (languages.docs || 0) + stat.size;
|
|
807
|
+
fileCount += 1;
|
|
808
|
+
} catch { /* ignore */ }
|
|
809
|
+
}
|
|
810
|
+
} catch { /* no docs dir — expected in greenfield */ }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const totalTokens = Math.ceil(totalBytes / CHARS_PER_TOKEN);
|
|
814
|
+
const mode = totalTokens <= threshold ? 'single-shot' : 'sharded';
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
total_bytes: totalBytes,
|
|
818
|
+
total_tokens: totalTokens,
|
|
819
|
+
threshold,
|
|
820
|
+
mode,
|
|
821
|
+
file_count: fileCount,
|
|
822
|
+
languages,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function cmdEstimateRepoSize(cwd, raw, args) {
|
|
827
|
+
const thresholdIdx = Array.isArray(args) ? args.indexOf('--threshold') : -1;
|
|
828
|
+
const threshold = thresholdIdx !== -1 && args[thresholdIdx + 1]
|
|
829
|
+
? Number(args[thresholdIdx + 1])
|
|
830
|
+
: undefined;
|
|
831
|
+
const noDocs = Array.isArray(args) && args.includes('--no-docs');
|
|
832
|
+
const opts = {};
|
|
833
|
+
if (threshold && Number.isFinite(threshold) && threshold > 0) opts.threshold = threshold;
|
|
834
|
+
if (noDocs) opts.include_docs = false;
|
|
835
|
+
const result = estimateRepoTokenSize(cwd, opts);
|
|
836
|
+
output(result, raw);
|
|
837
|
+
}
|
|
838
|
+
|
|
724
839
|
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
725
840
|
|
|
726
841
|
module.exports = {
|
|
@@ -743,4 +858,7 @@ module.exports = {
|
|
|
743
858
|
cmdDetectLanguages,
|
|
744
859
|
cmdAnalyzeImports,
|
|
745
860
|
cmdBestPractices,
|
|
861
|
+
cmdEstimateRepoSize,
|
|
862
|
+
// E-2
|
|
863
|
+
estimateRepoTokenSize,
|
|
746
864
|
};
|