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.
Files changed (75) hide show
  1. package/README.md +31 -9
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-distiller.md +82 -0
  6. package/agents/pan-document_code.md +21 -0
  7. package/agents/pan-executor.md +16 -0
  8. package/agents/pan-hardener.md +113 -0
  9. package/agents/pan-integration-checker.md +2 -0
  10. package/agents/pan-knowledge.md +81 -0
  11. package/agents/pan-meta-reviewer.md +91 -0
  12. package/agents/pan-optimizer.md +242 -0
  13. package/agents/pan-plan-checker.md +2 -0
  14. package/agents/pan-previewer.md +98 -0
  15. package/agents/pan-project-researcher.md +4 -4
  16. package/agents/pan-reviewer.md +2 -0
  17. package/agents/pan-verifier.md +2 -0
  18. package/bin/install-lib.cjs +197 -0
  19. package/bin/install.js +2048 -1959
  20. package/commands/pan/cost.md +132 -0
  21. package/commands/pan/exec-phase.md +15 -0
  22. package/commands/pan/focus-auto.md +168 -3
  23. package/commands/pan/focus-exec.md +21 -1
  24. package/commands/pan/focus-scan.md +6 -0
  25. package/commands/pan/git.md +223 -0
  26. package/commands/pan/knowledge.md +129 -0
  27. package/commands/pan/learn.md +61 -0
  28. package/commands/pan/map-codebase.md +15 -0
  29. package/commands/pan/mcp-bridge.md +145 -0
  30. package/commands/pan/milestone-done.md +9 -0
  31. package/commands/pan/optimize.md +86 -0
  32. package/commands/pan/plan-phase.md +11 -0
  33. package/commands/pan/preview.md +114 -0
  34. package/commands/pan/profile.md +37 -0
  35. package/commands/pan/review-deep.md +128 -0
  36. package/commands/pan/verify-phase.md +11 -0
  37. package/commands/pan/what-if.md +146 -0
  38. package/hooks/dist/pan-cost-logger.js +102 -0
  39. package/hooks/dist/pan-statusline.js +154 -108
  40. package/hooks/dist/pan-trace-logger.js +197 -0
  41. package/package.json +1 -1
  42. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  43. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  44. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  45. package/pan-wizard-core/bin/lib/commands.cjs +1 -0
  46. package/pan-wizard-core/bin/lib/constants.cjs +44 -1
  47. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  48. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  49. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  50. package/pan-wizard-core/bin/lib/distill.cjs +510 -0
  51. package/pan-wizard-core/bin/lib/focus.cjs +108 -3
  52. package/pan-wizard-core/bin/lib/git.cjs +407 -0
  53. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  54. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  55. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  56. package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
  57. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  58. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  59. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  60. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  61. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  62. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  63. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  64. package/pan-wizard-core/bin/pan-tools.cjs +317 -4
  65. package/pan-wizard-core/templates/playbook.md +53 -0
  66. package/pan-wizard-core/templates/preview-report.md +93 -0
  67. package/pan-wizard-core/templates/roadmap.md +24 -24
  68. package/pan-wizard-core/templates/state.md +12 -9
  69. package/pan-wizard-core/workflows/exec-phase.md +97 -0
  70. package/pan-wizard-core/workflows/learn.md +91 -0
  71. package/pan-wizard-core/workflows/optimize.md +139 -0
  72. package/pan-wizard-core/workflows/plan-phase.md +28 -1
  73. package/pan-wizard-core/workflows/quick.md +7 -0
  74. package/pan-wizard-core/workflows/verify-phase.md +16 -0
  75. 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
  };
@@ -1453,5 +1453,6 @@ module.exports = {
1453
1453
  cmdLearningsExtract,
1454
1454
  cmdLearningsList,
1455
1455
  cmdLearningsPrune,
1456
+ runCommitSafetyChecks,
1456
1457
  VALID_COMMIT_TYPES,
1457
1458
  };