@axplusb/kepler 1.0.10 → 2.0.2

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Keyboard dock — Mission Control (PRD-055 §5.4).
3
+ *
4
+ * Maps each orbit to the dock hint shown on the second line of the status
5
+ * bar. Pure data so tests, the status bar, and `/help` stay in sync.
6
+ *
7
+ * import { dockForOrbit } from './dock.mjs';
8
+ * const hints = dockForOrbit(orbit); // → [{ key, label }, ...]
9
+ * const text = renderDock(hints, paint); // → ANSI-styled single line
10
+ */
11
+
12
+ import { ORBITS } from '../state/orbit.mjs';
13
+ import { paint, width } from './palette.mjs';
14
+ import { term } from './term.mjs';
15
+
16
+ const DEFAULT = [
17
+ { key: 'Enter', label: 'send' },
18
+ { key: '/', label: 'commands' },
19
+ { key: '?', label: 'help' },
20
+ ];
21
+
22
+ const HINTS = {
23
+ [ORBITS.IDLE]: DEFAULT,
24
+ [ORBITS.DISCOVERY]: DEFAULT,
25
+
26
+ [ORBITS.PLANNING]: [
27
+ { key: 'p', label: 'pause' },
28
+ { key: 'Esc', label: 'interrupt' },
29
+ { key: '?', label: 'why' },
30
+ ],
31
+
32
+ [ORBITS.EXECUTION]: [
33
+ { key: 'd', label: 'last diff' },
34
+ { key: 'p', label: 'pause' },
35
+ { key: 'Esc', label: 'interrupt' },
36
+ ],
37
+
38
+ [ORBITS.ALIGNMENT]: [
39
+ { key: 'd', label: 'last result' },
40
+ { key: '?', label: 'explain' },
41
+ ],
42
+
43
+ [ORBITS.AWAITING]: [
44
+ { key: 'Enter', label: 'approve' },
45
+ { key: 'e', label: 'edit' },
46
+ { key: 'r', label: 're-plan' },
47
+ { key: 'n', label: 'reject' },
48
+ { key: '?', label: 'why' },
49
+ ],
50
+
51
+ [ORBITS.PAUSED]: [
52
+ { key: 'r', label: 'resume' },
53
+ { key: 'Esc', label: 'cancel' },
54
+ { key: 'q', label: 'quit' },
55
+ ],
56
+ };
57
+
58
+ /**
59
+ * Return the array of hints for the given orbit.
60
+ * Falls back to the default dock for unknown orbits so the bar is never empty.
61
+ */
62
+ export function dockForOrbit(orbit) {
63
+ return HINTS[orbit] || DEFAULT;
64
+ }
65
+
66
+ /**
67
+ * Render a hint list to an ANSI-styled line that fits within `maxWidth`
68
+ * visible characters. Drops trailing entries when they don't fit, never
69
+ * mid-hint truncates.
70
+ */
71
+ export function renderDock(hints, maxWidth = term().columns - 2) {
72
+ if (!Array.isArray(hints) || hints.length === 0) return '';
73
+ const parts = [];
74
+ let used = 0;
75
+ for (const h of hints) {
76
+ const piece = `[${h.key}] ${h.label}`;
77
+ const sep = parts.length ? ' ' : '';
78
+ const cost = width(sep) + width(piece);
79
+ if (used + cost > maxWidth) break;
80
+ parts.push(sep + paintHint(h));
81
+ used += cost;
82
+ }
83
+ return parts.join('');
84
+ }
85
+
86
+ function paintHint(h) {
87
+ return paint.text.dim('[') + paint.brand.data(h.key) + paint.text.dim('] ' + h.label);
88
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Brand icon registry — Mission Control (PRD-055 §4.2).
3
+ *
4
+ * Every CLI surface that prints a tool, phase, or status uses these icons.
5
+ * Each icon has a Unicode form and an ASCII fallback used when the terminal
6
+ * advertises no Unicode support (older Windows shells, locked-down CI).
7
+ *
8
+ * import { icons, icon } from './icons.mjs';
9
+ * process.stdout.write(`${icons.subAgent} explore ${icons.pass} pass`);
10
+ *
11
+ * Or by token (for data-driven tool display):
12
+ *
13
+ * icon('shell') // ⚙️
14
+ * icon('edit_file') // 🛠️
15
+ */
16
+
17
+ import { term } from './term.mjs';
18
+
19
+ // ── Canonical icons ──────────────────────────────────────────────────────
20
+
21
+ // Note: cannot Object.freeze() this — it is the target of a Proxy whose
22
+ // `get` returns a rendered string instead of the raw record, which violates
23
+ // the Proxy invariant for frozen targets.
24
+ const ICONS = ({
25
+ // Brand
26
+ brand: { utf: '✦', ascii: '*' },
27
+ orbit: { utf: '◯', ascii: 'O' },
28
+
29
+ // Tool families
30
+ subAgent: { utf: '🛰️', ascii: '~>' }, // explore, plan, verify, debug, refactor
31
+ search: { utf: '🔭', ascii: '?' }, // search_code, grep, read_file, list_files
32
+ write: { utf: '🛠️', ascii: '+' }, // write_file, edit_file, write_project
33
+ shell: { utf: '⚙️', ascii: '$' }, // shell, run_tests, validators
34
+ network: { utf: '🌐', ascii: '@' }, // WebFetch, MCP network calls
35
+
36
+ // State
37
+ pass: { utf: '✅', ascii: 'OK' },
38
+ warn: { utf: '⚠️', ascii: '!' },
39
+ fail: { utf: '❌', ascii: 'X' },
40
+ pending: { utf: '◔', ascii: '.' },
41
+
42
+ // Workflow
43
+ approve: { utf: '✔', ascii: 'Y' },
44
+ reject: { utf: '✘', ascii: 'N' },
45
+ pause: { utf: '⏸', ascii: '||' },
46
+ resume: { utf: '▶', ascii: '>' },
47
+ });
48
+
49
+ // Internal table that the lookup functions consult directly. This stays
50
+ // frozen because we never expose it through a Proxy.
51
+ const ICON_RECORDS = Object.freeze({ ...ICONS });
52
+
53
+ // ── Tool → icon mapping ──────────────────────────────────────────────────
54
+ // One source of truth so tool display, status bar, and mission report
55
+ // agree. Unknown tools fall through to a generic "tool" icon.
56
+
57
+ const TOOL_ICON = Object.freeze({
58
+ // Sub-agents
59
+ explore: 'subAgent',
60
+ plan: 'subAgent',
61
+ verify: 'subAgent',
62
+ debug: 'subAgent',
63
+ refactor: 'subAgent',
64
+
65
+ // Read / search
66
+ read_file: 'search',
67
+ read_files: 'search',
68
+ search_code: 'search',
69
+ search_files: 'search',
70
+ grep: 'search',
71
+ list_files: 'search',
72
+ analyze_code: 'search',
73
+ get_file_info: 'search',
74
+ git_diff: 'search',
75
+ git_status: 'search',
76
+ get_project_overview: 'search',
77
+
78
+ // Write / edit
79
+ write_file: 'write',
80
+ write_project: 'write',
81
+ edit_file: 'write',
82
+ delete_file: 'write',
83
+
84
+ // Shell / validate
85
+ shell: 'shell',
86
+ run_tests: 'shell',
87
+ validate_build: 'shell',
88
+ validate_file: 'shell',
89
+ validate_structure:'shell',
90
+ lint_check: 'shell',
91
+
92
+ // Network
93
+ WebFetch: 'network',
94
+ fetch_url: 'network',
95
+ });
96
+
97
+ // ── Helpers ──────────────────────────────────────────────────────────────
98
+
99
+ function render(spec) {
100
+ if (!spec) return '';
101
+ return term().unicode ? spec.utf : spec.ascii;
102
+ }
103
+
104
+ /**
105
+ * Resolve an icon by name (e.g. `icons.subAgent`). Always safe — returns
106
+ * the ASCII fallback when the terminal cannot render Unicode.
107
+ *
108
+ * Implemented as a Proxy over a plain object (intentionally not frozen, see
109
+ * the comment above ICONS) so callers can write `icons.pass` directly.
110
+ */
111
+ export const icons = new Proxy({}, {
112
+ get(_target, prop) {
113
+ if (typeof prop !== 'string') return undefined;
114
+ return render(ICON_RECORDS[prop]);
115
+ },
116
+ has(_target, prop) {
117
+ return typeof prop === 'string' && prop in ICON_RECORDS;
118
+ },
119
+ ownKeys() {
120
+ return Object.keys(ICON_RECORDS);
121
+ },
122
+ getOwnPropertyDescriptor(_target, prop) {
123
+ if (typeof prop !== 'string' || !(prop in ICON_RECORDS)) return undefined;
124
+ return { configurable: true, enumerable: true, value: render(ICON_RECORDS[prop]) };
125
+ },
126
+ });
127
+
128
+ /**
129
+ * Resolve the icon for a tool name. Falls back to a generic tool icon
130
+ * (`◇`) when the tool is unknown to the registry — this is intentional so
131
+ * unmapped MCP tools and user-defined tools still render with the same
132
+ * visual rhythm.
133
+ */
134
+ export function icon(toolName) {
135
+ if (!toolName) return '';
136
+ const key = TOOL_ICON[toolName];
137
+ if (key) return render(ICON_RECORDS[key]);
138
+
139
+ // MCP tools often arrive as "mcp__server__tool" — strip the prefix and
140
+ // try again before falling back to the generic glyph.
141
+ if (toolName.startsWith('mcp')) {
142
+ const cleaned = toolName.replace(/^mcp[_-]+/, '').split(/[_-]+/)[0];
143
+ const fallback = TOOL_ICON[cleaned];
144
+ if (fallback) return render(ICON_RECORDS[fallback]);
145
+ }
146
+
147
+ return term().unicode ? '◇' : '*';
148
+ }
149
+
150
+ /**
151
+ * Tool family (one of: subAgent, search, write, shell, network, other).
152
+ * Used by tier classification and color choice in the tool card renderer.
153
+ */
154
+ export function toolFamily(toolName) {
155
+ return TOOL_ICON[toolName] || 'other';
156
+ }
157
+
158
+ /**
159
+ * Lower-level lookup for callers that want to know whether a name is in
160
+ * the registry (e.g. risk classification fall-throughs).
161
+ */
162
+ export function hasIcon(name) {
163
+ return name in ICON_RECORDS;
164
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Mission report — Mission Control (PRD-055 §11).
3
+ *
4
+ * Replaces the trailing "Done" message at the end of a session with a
5
+ * structured summary:
6
+ *
7
+ * ───────────────────────────────────────────────────
8
+ * ✅ MISSION ACCOMPLISHED · Fix JWT expiration bug
9
+ * ───────────────────────────────────────────────────
10
+ * 📂 Files auth.py, tests/test_auth.py
11
+ * 🛠️ Tools read(4) edit(2) shell(1) test(1)
12
+ * 🛰️ Sub-agents explore(1) plan(1) · saved ≈ $0.08
13
+ * 💰 Cost $0.14 ⏱ Time 2m 18s
14
+ * ✅ Health 24/24 tests pass
15
+ * ───────────────────────────────────────────────────
16
+ *
17
+ * Next: /commit /pr /undo /report
18
+ *
19
+ * Failure variant (PRD §11.1) uses ❌ MISSION HELD and lists blockers.
20
+ *
21
+ * `renderMissionReport(state)` returns the ANSI block; `toMarkdown(state)`
22
+ * returns the plain-markdown version saved by `/report`.
23
+ */
24
+
25
+ import path from 'node:path';
26
+ import fs from 'node:fs';
27
+ import { paint, width as visibleWidth } from './palette.mjs';
28
+ import { icons } from './icons.mjs';
29
+ import { toolFamily } from './icons.mjs';
30
+
31
+ const WIDTH = 56;
32
+
33
+ // ── Public API ─────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Render the ANSI mission-report block.
37
+ *
38
+ * @param {object} state
39
+ * task — string (the user's prompt for this session)
40
+ * success — boolean (overall outcome)
41
+ * filesChanged — string[]
42
+ * toolCounts — { [tool]: count } or array of {tool}
43
+ * subAgents — array of { type, costUsd?, tokens? } or { explore:1, plan:1 }
44
+ * costUsd — number
45
+ * durationS — number
46
+ * testsPass — { passed: number, total: number } | null
47
+ * blockers — string[] (for failure variant)
48
+ * nextActions — string[] (slash-command hints)
49
+ */
50
+ export function renderMissionReport(state) {
51
+ const success = state.success !== false;
52
+ const lines = [];
53
+ const rule = paint.text.dim('─'.repeat(WIDTH));
54
+
55
+ const titleIcon = success ? paint.state.success('✅') : paint.state.danger('❌');
56
+ const titleText = success ? 'MISSION ACCOMPLISHED' : 'MISSION HELD';
57
+ const titleAccent = success ? paint.state.success : paint.state.danger;
58
+ const headerTask = state.task ? paint.text.dim(' · ') + paint.text.primary(truncate(state.task, 60)) : '';
59
+
60
+ lines.push('');
61
+ lines.push(rule);
62
+ lines.push(`${titleIcon} ${paint.bold(titleAccent(titleText))}${headerTask}`);
63
+ lines.push(rule);
64
+
65
+ if (Array.isArray(state.filesChanged) && state.filesChanged.length) {
66
+ lines.push(row('📂', 'Files', formatFiles(state.filesChanged)));
67
+ }
68
+
69
+ const toolSummary = formatToolCounts(state.toolCounts);
70
+ if (toolSummary) {
71
+ lines.push(row(icons.write, 'Tools', toolSummary));
72
+ }
73
+
74
+ if (state.subAgents) {
75
+ const subSummary = formatSubAgents(state.subAgents);
76
+ if (subSummary) lines.push(row(icons.subAgent, 'Sub-agents', subSummary));
77
+ }
78
+
79
+ // Cost + time on one row.
80
+ const cost = state.costUsd != null ? paint.brand.data(formatCost(state.costUsd)) : '';
81
+ const time = state.durationS != null ? paint.brand.data(formatDuration(state.durationS)) : '';
82
+ if (cost || time) {
83
+ const segments = [];
84
+ if (cost) segments.push(`${paint.text.dim('💰 Cost')} ${cost}`);
85
+ if (time) segments.push(`${paint.text.dim('⏱ Time')} ${time}`);
86
+ lines.push(' ' + segments.join(' '));
87
+ }
88
+
89
+ // Test health.
90
+ if (state.testsPass && typeof state.testsPass.total === 'number' && state.testsPass.total > 0) {
91
+ const { passed = 0, total = 0 } = state.testsPass;
92
+ const allGreen = passed === total;
93
+ const icon = allGreen ? paint.state.success('✅') : paint.state.danger('❌');
94
+ const text = allGreen
95
+ ? `${passed}/${total} tests pass`
96
+ : `${passed}/${total} tests pass · ${paint.state.danger((total - passed) + ' failing')}`;
97
+ lines.push(row(icon, allGreen ? 'Health' : 'Tests', text, /*alreadyIcon*/ true));
98
+ }
99
+
100
+ lines.push(rule);
101
+
102
+ if (!success && Array.isArray(state.blockers) && state.blockers.length) {
103
+ lines.push('');
104
+ lines.push(' ' + paint.bold(paint.state.danger('Blocked by:')));
105
+ for (const b of state.blockers.slice(0, 6)) {
106
+ lines.push(' ' + paint.text.dim('•') + ' ' + paint.text.primary(truncate(b, WIDTH * 2)));
107
+ }
108
+ if (state.blockers.length > 6) {
109
+ lines.push(' ' + paint.text.dim(`… ${state.blockers.length - 6} more`));
110
+ }
111
+ }
112
+
113
+ if (Array.isArray(state.nextActions) && state.nextActions.length) {
114
+ lines.push('');
115
+ const next = state.nextActions.map(a => paint.brand.data(a)).join(paint.text.dim(' '));
116
+ lines.push(' ' + paint.text.dim('Next: ') + next);
117
+ }
118
+
119
+ lines.push('');
120
+ return lines.join('\n');
121
+ }
122
+
123
+ /**
124
+ * Same content as renderMissionReport, but as plain markdown so callers
125
+ * can persist it under `.kepler/reports/`.
126
+ */
127
+ export function toMarkdown(state) {
128
+ const success = state.success !== false;
129
+ const out = [];
130
+ out.push(`# ${success ? '✅ Mission Accomplished' : '❌ Mission Held'}${state.task ? ' — ' + state.task : ''}`);
131
+ out.push('');
132
+ if (Array.isArray(state.filesChanged) && state.filesChanged.length) {
133
+ out.push('**Files**: ' + state.filesChanged.join(', '));
134
+ }
135
+ const toolSummary = stripAnsi(formatToolCounts(state.toolCounts) || '');
136
+ if (toolSummary) out.push('**Tools**: ' + toolSummary);
137
+ if (state.subAgents) {
138
+ const sub = stripAnsi(formatSubAgents(state.subAgents) || '');
139
+ if (sub) out.push('**Sub-agents**: ' + sub);
140
+ }
141
+ if (state.costUsd != null) out.push('**Cost**: ' + stripAnsi(formatCost(state.costUsd)));
142
+ if (state.durationS != null) out.push('**Time**: ' + formatDuration(state.durationS));
143
+ if (state.testsPass) {
144
+ const { passed = 0, total = 0 } = state.testsPass;
145
+ out.push(`**Tests**: ${passed}/${total} ${passed === total ? 'pass' : 'pass · ' + (total - passed) + ' failing'}`);
146
+ }
147
+ if (!success && Array.isArray(state.blockers) && state.blockers.length) {
148
+ out.push('');
149
+ out.push('## Blocked by');
150
+ for (const b of state.blockers) out.push('- ' + b);
151
+ }
152
+ if (Array.isArray(state.nextActions) && state.nextActions.length) {
153
+ out.push('');
154
+ out.push('**Next**: ' + state.nextActions.join(' '));
155
+ }
156
+ out.push('');
157
+ return out.join('\n');
158
+ }
159
+
160
+ /**
161
+ * Save a markdown copy of the report to `.kepler/reports/<timestamp>.md`
162
+ * inside the working directory. Returns the absolute path.
163
+ */
164
+ export function saveReport(state, { cwd = process.cwd(), timestamp } = {}) {
165
+ const dir = path.join(cwd, '.kepler', 'reports');
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ const stamp = timestamp || new Date().toISOString().replace(/[:.]/g, '-');
168
+ const out = path.join(dir, `${stamp}.md`);
169
+ fs.writeFileSync(out, toMarkdown(state));
170
+ return out;
171
+ }
172
+
173
+ // ── Helpers ────────────────────────────────────────────────────────────
174
+
175
+ function row(icon, label, value, alreadyIcon = false) {
176
+ const i = alreadyIcon ? icon : icon;
177
+ const labelText = paint.text.dim(label.padEnd(11));
178
+ return ` ${i} ${labelText} ${value}`;
179
+ }
180
+
181
+ function formatFiles(files) {
182
+ const shortened = files.map(f => paint.text.primary(path.basename(f)));
183
+ if (shortened.length <= 4) return shortened.join(paint.text.dim(', '));
184
+ return shortened.slice(0, 4).join(paint.text.dim(', ')) + paint.text.dim(`, +${files.length - 4} more`);
185
+ }
186
+
187
+ /**
188
+ * Render `read(4) edit(2) shell(1) test(1)` from a counts object/array.
189
+ * Buckets by tool family so the line stays compact.
190
+ */
191
+ function formatToolCounts(counts) {
192
+ if (!counts) return '';
193
+ const entries = Array.isArray(counts)
194
+ ? counts
195
+ : Object.entries(counts).map(([tool, n]) => ({ tool, count: n }));
196
+ if (!entries.length) return '';
197
+
198
+ const buckets = { read: 0, edit: 0, shell: 0, test: 0, other: 0 };
199
+ for (const { tool, count } of entries) {
200
+ const c = Number(count) || 0;
201
+ if (!c) continue;
202
+ const fam = toolFamily(tool);
203
+ if (tool === 'run_tests' || tool === 'validate_build') buckets.test += c;
204
+ else if (fam === 'write') buckets.edit += c;
205
+ else if (fam === 'shell') buckets.shell += c;
206
+ else if (fam === 'search') buckets.read += c;
207
+ else buckets.other += c;
208
+ }
209
+ const parts = [];
210
+ if (buckets.read) parts.push(`${paint.brand.data('read')}(${buckets.read})`);
211
+ if (buckets.edit) parts.push(`${paint.brand.primary('edit')}(${buckets.edit})`);
212
+ if (buckets.shell) parts.push(`${paint.state.warn('shell')}(${buckets.shell})`);
213
+ if (buckets.test) parts.push(`${paint.state.success('test')}(${buckets.test})`);
214
+ if (buckets.other) parts.push(`${paint.text.muted('tool')}(${buckets.other})`);
215
+ return parts.join(paint.text.dim(' '));
216
+ }
217
+
218
+ function formatSubAgents(subAgents) {
219
+ // Accepts either a flat counts object { explore: 1, plan: 1, savedUsd: 0.08 }
220
+ // or an array of { type, costUsd, tokens }.
221
+ if (!subAgents) return '';
222
+ let counts = {};
223
+ let savedUsd = 0;
224
+ if (Array.isArray(subAgents)) {
225
+ for (const s of subAgents) {
226
+ counts[s.type] = (counts[s.type] || 0) + 1;
227
+ if (typeof s.savedUsd === 'number') savedUsd += s.savedUsd;
228
+ }
229
+ } else {
230
+ counts = { ...subAgents };
231
+ savedUsd = subAgents.savedUsd || 0;
232
+ delete counts.savedUsd;
233
+ }
234
+ const entries = Object.entries(counts).filter(([, n]) => Number(n) > 0);
235
+ if (!entries.length) return '';
236
+ const list = entries.map(([type, n]) => `${paint.brand.data(type)}(${n})`).join(paint.text.dim(' '));
237
+ if (savedUsd > 0) {
238
+ return list + paint.text.dim(` · saved ≈ ${formatCost(savedUsd)}`);
239
+ }
240
+ return list;
241
+ }
242
+
243
+ function formatCost(usd) {
244
+ if (typeof usd !== 'number' || !Number.isFinite(usd)) return '$0.00';
245
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
246
+ return `$${usd.toFixed(2)}`;
247
+ }
248
+
249
+ function formatDuration(s) {
250
+ if (typeof s !== 'number' || !Number.isFinite(s)) return '0s';
251
+ if (s < 60) return `${s.toFixed(1)}s`;
252
+ const m = Math.floor(s / 60);
253
+ const rem = Math.round(s - m * 60);
254
+ return `${m}m ${rem}s`;
255
+ }
256
+
257
+ function truncate(s, n) {
258
+ const str = String(s || '');
259
+ return str.length <= n ? str : str.slice(0, n - 1) + '…';
260
+ }
261
+
262
+ function stripAnsi(s) {
263
+ return String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
264
+ }