@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10

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 (74) hide show
  1. package/README.md +33 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/assets/pugi-mascot.ansi +16 -0
  4. package/dist/commands/deploy.js +439 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +1 -1
  7. package/dist/core/consensus/anvil-fanout.js +276 -0
  8. package/dist/core/consensus/diff-capture.js +382 -0
  9. package/dist/core/consensus/rubric.js +233 -0
  10. package/dist/core/context/index.js +21 -0
  11. package/dist/core/context/pugiignore.js +316 -0
  12. package/dist/core/context/repo-skeleton.js +533 -0
  13. package/dist/core/context/watcher.js +342 -0
  14. package/dist/core/context/working-set.js +165 -0
  15. package/dist/core/edits/dispatch.js +185 -0
  16. package/dist/core/edits/index.js +15 -0
  17. package/dist/core/edits/layer-a-apply.js +217 -0
  18. package/dist/core/edits/layer-b-apply.js +211 -0
  19. package/dist/core/edits/layer-c-apply.js +160 -0
  20. package/dist/core/edits/layer-d-ast.js +29 -0
  21. package/dist/core/edits/marker-parser.js +401 -0
  22. package/dist/core/edits/security-gate.js +223 -0
  23. package/dist/core/edits/worktree.js +322 -0
  24. package/dist/core/engine/native-pugi.js +6 -1
  25. package/dist/core/engine/prompts.js +8 -0
  26. package/dist/core/engine/tool-bridge.js +33 -1
  27. package/dist/core/lsp/client.js +719 -0
  28. package/dist/core/repl/ask.js +512 -0
  29. package/dist/core/repl/cancellation.js +98 -0
  30. package/dist/core/repl/dispatch-fsm.js +220 -0
  31. package/dist/core/repl/privacy-banner.js +71 -0
  32. package/dist/core/repl/session.js +1908 -13
  33. package/dist/core/repl/slash-commands.js +92 -32
  34. package/dist/core/repl/store/index.js +12 -0
  35. package/dist/core/repl/store/jsonl-log.js +321 -0
  36. package/dist/core/repl/store/lockfile.js +155 -0
  37. package/dist/core/repl/store/session-store.js +792 -0
  38. package/dist/core/repl/store/types.js +44 -0
  39. package/dist/core/repl/store/uuid-v7.js +68 -0
  40. package/dist/core/repl/workspace-context.js +72 -1
  41. package/dist/core/skills/defaults.js +457 -0
  42. package/dist/core/skills/loader.js +454 -0
  43. package/dist/core/skills/sources.js +480 -0
  44. package/dist/core/skills/trust.js +172 -0
  45. package/dist/runtime/cli.js +998 -12
  46. package/dist/runtime/commands/agents.js +385 -0
  47. package/dist/runtime/commands/config.js +338 -8
  48. package/dist/runtime/commands/delegate.js +289 -0
  49. package/dist/runtime/commands/lsp.js +206 -0
  50. package/dist/runtime/commands/patch.js +128 -0
  51. package/dist/runtime/commands/review-consensus.js +399 -0
  52. package/dist/runtime/commands/roster.js +117 -0
  53. package/dist/runtime/commands/skills.js +401 -0
  54. package/dist/runtime/commands/worktree.js +177 -0
  55. package/dist/runtime/plan-decompose.js +531 -0
  56. package/dist/tools/apply-patch.js +495 -0
  57. package/dist/tools/file-tools.js +90 -0
  58. package/dist/tools/lsp-tools.js +189 -0
  59. package/dist/tools/registry.js +26 -0
  60. package/dist/tools/web-fetch.js +1 -1
  61. package/dist/tui/agent-tree-pane.js +9 -0
  62. package/dist/tui/ask-cli.js +52 -0
  63. package/dist/tui/ask-modal.js +211 -0
  64. package/dist/tui/conversation-pane.js +48 -3
  65. package/dist/tui/input-box.js +48 -5
  66. package/dist/tui/markdown-render.js +266 -0
  67. package/dist/tui/repl-render.js +319 -3
  68. package/dist/tui/repl-splash-mascot.js +130 -0
  69. package/dist/tui/repl-splash.js +7 -1
  70. package/dist/tui/repl.js +96 -12
  71. package/dist/tui/status-bar.js +63 -3
  72. package/dist/tui/tool-stream-pane.js +91 -0
  73. package/docs/examples/codegraph.mcp.json +10 -0
  74. package/package.json +14 -6
@@ -0,0 +1,189 @@
1
+ import { gateOnCancellation, OperatorAbortedError } from './file-tools.js';
2
+ import { recordToolCall, recordToolResult } from '../core/session.js';
3
+ /** Cap for any single LSP tool's payload size. Keeps model context lean. */
4
+ const LSP_PAYLOAD_CAP_BYTES = 8 * 1024;
5
+ export async function lspHover(ctx, lang, file, line, col) {
6
+ const toolCallId = recordToolCall(ctx.session, 'lsp_hover', `${lang}:${file}:${line}:${col}`);
7
+ return guard(ctx, 'lsp_hover', toolCallId, async () => {
8
+ const client = ctx.lspClients?.get(lang);
9
+ if (!client)
10
+ return unavailable(lang);
11
+ const result = await client.hover(file, { line, character: col }, ctx.cancellation);
12
+ if (!result.ok)
13
+ return failure(result);
14
+ if (!result.value) {
15
+ return { ok: true, value: { content: '' } };
16
+ }
17
+ const content = truncate(result.value.content);
18
+ return {
19
+ ok: true,
20
+ value: {
21
+ content: content.text,
22
+ ...(result.value.range ? { range: result.value.range } : {}),
23
+ },
24
+ ...(content.truncated ? { truncated: true } : {}),
25
+ };
26
+ });
27
+ }
28
+ export async function lspDefinition(ctx, lang, file, line, col) {
29
+ const toolCallId = recordToolCall(ctx.session, 'lsp_definition', `${lang}:${file}:${line}:${col}`);
30
+ return guard(ctx, 'lsp_definition', toolCallId, async () => {
31
+ const client = ctx.lspClients?.get(lang);
32
+ if (!client)
33
+ return unavailable(lang);
34
+ const result = await client.definition(file, { line, character: col }, ctx.cancellation);
35
+ if (!result.ok)
36
+ return failure(result);
37
+ const capped = capLocations(result.value);
38
+ return {
39
+ ok: true,
40
+ value: capped.value,
41
+ ...(capped.truncated ? { truncated: true } : {}),
42
+ };
43
+ });
44
+ }
45
+ export async function lspReferences(ctx, lang, file, line, col) {
46
+ const toolCallId = recordToolCall(ctx.session, 'lsp_references', `${lang}:${file}:${line}:${col}`);
47
+ return guard(ctx, 'lsp_references', toolCallId, async () => {
48
+ const client = ctx.lspClients?.get(lang);
49
+ if (!client)
50
+ return unavailable(lang);
51
+ const result = await client.references(file, { line, character: col }, ctx.cancellation);
52
+ if (!result.ok)
53
+ return failure(result);
54
+ const capped = capLocations(result.value);
55
+ return {
56
+ ok: true,
57
+ value: capped.value,
58
+ ...(capped.truncated ? { truncated: true } : {}),
59
+ };
60
+ });
61
+ }
62
+ export async function lspDiagnostics(ctx, lang, file) {
63
+ const toolCallId = recordToolCall(ctx.session, 'lsp_diagnostics', `${lang}:${file}`);
64
+ return guard(ctx, 'lsp_diagnostics', toolCallId, async () => {
65
+ const client = ctx.lspClients?.get(lang);
66
+ if (!client)
67
+ return unavailable(lang);
68
+ const result = await client.diagnostics(file, ctx.cancellation);
69
+ if (!result.ok)
70
+ return failure(result);
71
+ const capped = capDiagnostics(result.value);
72
+ return {
73
+ ok: true,
74
+ value: capped.value,
75
+ ...(capped.truncated ? { truncated: true } : {}),
76
+ };
77
+ });
78
+ }
79
+ async function guard(ctx, toolName, toolCallId, op) {
80
+ try {
81
+ gateOnCancellation(ctx, toolName);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof OperatorAbortedError) {
85
+ recordToolResult(ctx.session, toolCallId, 'cancelled', error.message);
86
+ return { ok: false, reason: 'operator_aborted', detail: error.message };
87
+ }
88
+ throw error;
89
+ }
90
+ try {
91
+ const result = await op();
92
+ if (result.ok) {
93
+ recordToolResult(ctx.session, toolCallId, 'success', summarize(result.value));
94
+ }
95
+ else {
96
+ recordToolResult(ctx.session, toolCallId, 'error', `${result.reason ?? 'error'}: ${result.detail ?? ''}`);
97
+ }
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ recordToolResult(ctx.session, toolCallId, 'error', message);
103
+ return { ok: false, reason: 'lsp_error', detail: message };
104
+ }
105
+ }
106
+ function unavailable(lang) {
107
+ return {
108
+ ok: false,
109
+ reason: 'lsp_unavailable',
110
+ detail: `no LSP server started for ${lang}. Install the server and re-run ` +
111
+ `with --lsp ${lang}, or fall back to grep.`,
112
+ };
113
+ }
114
+ function failure(result) {
115
+ if (result.ok) {
116
+ // Shouldn't be hit — caller checks first.
117
+ return { ok: true, value: result.value };
118
+ }
119
+ return { ok: false, reason: result.reason, detail: result.detail };
120
+ }
121
+ function summarize(value) {
122
+ if (value === null || value === undefined)
123
+ return 'no result';
124
+ if (Array.isArray(value))
125
+ return `${value.length} items`;
126
+ if (typeof value === 'object')
127
+ return Object.keys(value).join(',');
128
+ return String(value);
129
+ }
130
+ function truncate(text) {
131
+ const bytes = Buffer.byteLength(text, 'utf8');
132
+ if (bytes <= LSP_PAYLOAD_CAP_BYTES)
133
+ return { text, truncated: false };
134
+ // Truncate to the cap byte boundary. We don't try to honor codepoint
135
+ // alignment — UTF-8 surrogate splits show up as a single ? at the
136
+ // boundary, which is acceptable for a debug surface; the dispatcher
137
+ // is the trust boundary for "this is what the model will see".
138
+ const buf = Buffer.from(text, 'utf8').subarray(0, LSP_PAYLOAD_CAP_BYTES);
139
+ return { text: `${buf.toString('utf8')}\n... [truncated]`, truncated: true };
140
+ }
141
+ function capLocations(locations) {
142
+ // Cap at 200 locations OR the byte cap, whichever hits first. The
143
+ // 200 number is the operator-facing "this is a hot symbol" threshold —
144
+ // a richer surface (paginated `pugi lsp references --offset N`) is
145
+ // open backlog.
146
+ const COUNT_CAP = 200;
147
+ if (locations.length === 0)
148
+ return { value: locations, truncated: false };
149
+ const trimmed = locations.slice(0, COUNT_CAP);
150
+ const serialized = JSON.stringify(trimmed);
151
+ if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES && trimmed.length === locations.length) {
152
+ return { value: trimmed, truncated: false };
153
+ }
154
+ // Trim by halves until we fit the byte cap. Worst case ~10 iterations
155
+ // for the 200 max, fine for an interactive tool.
156
+ let upper = trimmed.length;
157
+ while (upper > 1) {
158
+ const half = Math.floor(upper / 2);
159
+ const sub = trimmed.slice(0, half);
160
+ if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
161
+ return { value: sub, truncated: true };
162
+ }
163
+ upper = half;
164
+ }
165
+ return { value: trimmed.slice(0, 1), truncated: true };
166
+ }
167
+ function capDiagnostics(items) {
168
+ if (items.length === 0)
169
+ return { value: items, truncated: false };
170
+ const serialized = JSON.stringify(items);
171
+ if (Buffer.byteLength(serialized, 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
172
+ return { value: items, truncated: false };
173
+ }
174
+ // Diagnostics are sorted error-first in LSP convention; trim from the
175
+ // tail so we keep the highest-severity items.
176
+ let upper = items.length;
177
+ while (upper > 1) {
178
+ const half = Math.floor(upper / 2);
179
+ const sub = items.slice(0, half);
180
+ if (Buffer.byteLength(JSON.stringify(sub), 'utf8') <= LSP_PAYLOAD_CAP_BYTES) {
181
+ return { value: sub, truncated: true };
182
+ }
183
+ upper = half;
184
+ }
185
+ return { value: items.slice(0, 1), truncated: true };
186
+ }
187
+ /** Test-only surface so specs can poke truncation directly. */
188
+ export const __test__ = { truncate, capLocations, capDiagnostics, LSP_PAYLOAD_CAP_BYTES };
189
+ //# sourceMappingURL=lsp-tools.js.map
@@ -1,8 +1,19 @@
1
1
  const registry = [
2
+ // α7.7: unified-diff patch apply. Routes through the same security
3
+ // gate as Layer A/B/C, so the risk class matches `edit`/`write`
4
+ // (medium — writes inside the workspace, never to protected files).
5
+ { name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
2
6
  { name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
3
7
  { name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
4
8
  { name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
5
9
  { name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
10
+ // α7.7: LSP read-only surface. Server runs locally, no Anvil
11
+ // round-trip. Concurrency-safe because every operation reads
12
+ // server state without mutating workspace files.
13
+ { name: 'lsp_definition', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
14
+ { name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
15
+ { name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
16
+ { name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
6
17
  { name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
7
18
  { name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
8
19
  { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
@@ -11,6 +22,21 @@ const registry = [
11
22
  { name: 'task_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
12
23
  { name: 'task_update', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
13
24
  { name: 'web_fetch', permission: 'network', risk: 'medium', concurrencySafe: true, m1: true },
25
+ // α7.7: scratch worktree management. `worktree_create` writes nothing
26
+ // dangerous (a clone under `.pugi/worktrees/`); `worktree_promote`
27
+ // applies a diff back to the main tree, so it shares the `edit`
28
+ // risk class. `worktree_drop` is the cleanup primitive.
29
+ //
30
+ // R1 fix (2026-05-26, PR #413 r1, Fix 9): raised `worktree_create`
31
+ // and `worktree_drop` from `low` to `medium`. `worktree_drop` runs
32
+ // `rmSync` on its target — even with the new path-containment gate
33
+ // in `core/edits/worktree.ts::dropWorktree`, a destructive primitive
34
+ // belongs in `medium` so the permission FSM prompts on every call.
35
+ // `worktree_create` is raised for disk-pressure parity (a runaway
36
+ // agent loop could fill the disk with abandoned scratch worktrees).
37
+ { name: 'worktree_create', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
38
+ { name: 'worktree_drop', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
39
+ { name: 'worktree_promote', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
14
40
  { name: 'write', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
15
41
  ];
16
42
  export const toolRegistry = registry.sort((a, b) => a.name.localeCompare(b.name));
@@ -240,7 +240,7 @@ function ipv4IsBlocked(ip) {
240
240
  * a literal IP (with brackets stripped). We honor that fast-path and
241
241
  * skip DNS.
242
242
  */
243
- async function validateHostnameForFetch(hostname) {
243
+ export async function validateHostnameForFetch(hostname) {
244
244
  // URL.hostname keeps the brackets off IPv6 literals already.
245
245
  if (!hostname)
246
246
  return 'empty hostname';
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { AgentTree } from './agent-tree.js';
4
+ export function AgentTreePane(props) {
5
+ const onWatch = props.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
6
+ const total = props.agents.length;
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ agents ' }), _jsx(Text, { dimColor: true, children: `(${total} total, ${onWatch} on watch)` })] }), _jsx(AgentTree, { agents: props.agents, nowEpochMs: props.nowEpochMs })] }));
8
+ }
9
+ //# sourceMappingURL=agent-tree-pane.js.map
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, useApp } from 'ink';
3
+ import { AskModal, PlanReviewModal } from './ask-modal.js';
4
+ export async function renderAskCli(options) {
5
+ let resolveOuter;
6
+ const outerPromise = new Promise((resolve) => {
7
+ resolveOuter = resolve;
8
+ });
9
+ function App() {
10
+ const { exit } = useApp();
11
+ return (_jsx(AskModal, { tag: options.tag, onResolve: (verdict) => {
12
+ resolveOuter(verdict);
13
+ // Slight delay so Ink flushes the unmount before the parent
14
+ // CLI prints the verdict line. Otherwise the modal frame and
15
+ // the verdict line can interleave on slow terminals.
16
+ setTimeout(() => exit(), 16);
17
+ } }));
18
+ }
19
+ const instance = render(_jsx(App, {}));
20
+ const verdict = await outerPromise;
21
+ try {
22
+ await instance.waitUntilExit();
23
+ }
24
+ catch {
25
+ // Ink may throw if exit() races with a re-render; the verdict is
26
+ // already captured so we ignore.
27
+ }
28
+ return verdict;
29
+ }
30
+ export async function renderPlanReviewCli(options) {
31
+ let resolveOuter;
32
+ const outerPromise = new Promise((resolve) => {
33
+ resolveOuter = resolve;
34
+ });
35
+ function App() {
36
+ const { exit } = useApp();
37
+ return (_jsx(PlanReviewModal, { tag: options.tag, onResolve: (result) => {
38
+ resolveOuter(result);
39
+ setTimeout(() => exit(), 16);
40
+ } }));
41
+ }
42
+ const instance = render(_jsx(App, {}));
43
+ const result = await outerPromise;
44
+ try {
45
+ await instance.waitUntilExit();
46
+ }
47
+ catch {
48
+ // See renderAskCli — captured verdict supersedes a late Ink throw.
49
+ }
50
+ return result;
51
+ }
52
+ //# sourceMappingURL=ask-cli.js.map
@@ -0,0 +1,211 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Office-hours forcing questions + plan-review modals - Sprint α6.3.
4
+ *
5
+ * Two Ink components feed by the parsed `<pugi-ask>` / `<pugi-plan-review>`
6
+ * tag records the session module extracts from persona output.
7
+ *
8
+ * - <AskModal />: numbered options (1-4) + optional "Other" custom-input
9
+ * fallback. Operator types `1`, `2`, `3`, `4`, or `o <free text>`.
10
+ *
11
+ * - <PlanReviewModal />: numbered steps + optional risk callout +
12
+ * three-way verdict `[a] approve · [m] modify · [c] cancel`.
13
+ * Operator types `a`, `m`, or `c`. `m` opens a free-text editor;
14
+ * the operator's edited text is returned verbatim to the session.
15
+ *
16
+ * Both components are PURE in the Ink sense — they read props + own
17
+ * useState for the input buffer, and emit one final `onResolve` callback
18
+ * when the operator submits. The REPL root handles wiring the verdict
19
+ * back into the session as the next user turn (prefixed with
20
+ * `[ASK-RESPONSE:<value>]` / `[PLAN-VERDICT:approve|modify|cancel] ...`)
21
+ * so the persona transcript stays linear.
22
+ *
23
+ * Brand voice gate: ASCII glyphs only, no em-dashes, no banned brand
24
+ * words (journey, explore, delight, magical, friendly, AI-powered,
25
+ * pug-tastic). The modal copy is power-word neutral so a localized
26
+ * variant lands cleanly later.
27
+ */
28
+ import { useState } from 'react';
29
+ import { Box, Text, useInput } from 'ink';
30
+ export function AskModal(props) {
31
+ const [mode, setMode] = useState('pick');
32
+ const [buffer, setBuffer] = useState('');
33
+ useInput((input, key) => {
34
+ // Esc cancels the modal in either mode.
35
+ if (key.escape) {
36
+ props.onResolve({ value: '', cancelled: true });
37
+ return;
38
+ }
39
+ if (mode === 'pick') {
40
+ // Numeric keys 1..N select the matching option, capped at the
41
+ // total option count. Out-of-range keys are ignored.
42
+ const numeric = Number.parseInt(input, 10);
43
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= props.tag.options.length) {
44
+ const opt = props.tag.options[numeric - 1];
45
+ if (opt) {
46
+ props.onResolve({ value: opt.value, cancelled: false });
47
+ return;
48
+ }
49
+ }
50
+ // The "Other" sentinel is the index = options.length + 1. We
51
+ // also accept `o` (lowercase) as a hotkey since the cursor key
52
+ // for "Other" is always one-past-the-last numeric.
53
+ const otherIndex = props.tag.options.length + 1;
54
+ if ((!Number.isNaN(numeric) && numeric === otherIndex)
55
+ || input === 'o'
56
+ || input === 'O') {
57
+ setMode('custom');
58
+ setBuffer('');
59
+ return;
60
+ }
61
+ // Any other keystroke: ignored. The hint footer tells the
62
+ // operator the legal keys.
63
+ return;
64
+ }
65
+ // Custom-input mode: line editor.
66
+ if (key.return) {
67
+ // Empty buffer + Enter = cancel custom (return to pick).
68
+ if (buffer.trim().length === 0) {
69
+ setMode('pick');
70
+ setBuffer('');
71
+ return;
72
+ }
73
+ props.onResolve({
74
+ value: '',
75
+ customInput: buffer.trim(),
76
+ cancelled: false,
77
+ });
78
+ return;
79
+ }
80
+ if (key.backspace || key.delete) {
81
+ setBuffer((prev) => prev.slice(0, -1));
82
+ return;
83
+ }
84
+ if (input && !key.meta && !key.ctrl) {
85
+ setBuffer((prev) => prev + input);
86
+ }
87
+ }, { isActive: props.inert !== true });
88
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: '? ' }), _jsx(Text, { bold: true, children: 'Need your call before I continue' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: props.tag.question }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [props.tag.options.map((opt, idx) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: opt.label })] }), opt.desc ? (_jsx(Box, { marginLeft: 5, children: _jsx(Text, { dimColor: true, children: opt.desc }) })) : null] }, opt.value))), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${props.tag.options.length + 1}. ` }), _jsx(Text, { dimColor: true, children: 'Other (type a custom answer)' })] })] }), mode === 'pick' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `Press 1-${props.tag.options.length + 1} to choose. Esc cancels.` }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type your custom answer. Enter submits. Esc cancels.' }) })] }))] }));
89
+ }
90
+ export function PlanReviewModal(props) {
91
+ const [mode, setMode] = useState('pick');
92
+ const [buffer, setBuffer] = useState('');
93
+ useInput((input, key) => {
94
+ if (key.escape) {
95
+ // Escape from EITHER mode means cancel — symmetric with AskModal.
96
+ props.onResolve({ verdict: 'cancel' });
97
+ return;
98
+ }
99
+ if (mode === 'pick') {
100
+ if (input === 'a' || input === 'A') {
101
+ props.onResolve({ verdict: 'approve' });
102
+ return;
103
+ }
104
+ if (input === 'c' || input === 'C') {
105
+ props.onResolve({ verdict: 'cancel' });
106
+ return;
107
+ }
108
+ if (input === 'm' || input === 'M') {
109
+ setMode('modify');
110
+ setBuffer('');
111
+ return;
112
+ }
113
+ return;
114
+ }
115
+ // Modify-mode line editor.
116
+ if (key.return) {
117
+ if (buffer.trim().length === 0) {
118
+ setMode('pick');
119
+ setBuffer('');
120
+ return;
121
+ }
122
+ props.onResolve({ verdict: 'modify', modifyText: buffer.trim() });
123
+ return;
124
+ }
125
+ if (key.backspace || key.delete) {
126
+ setBuffer((prev) => prev.slice(0, -1));
127
+ return;
128
+ }
129
+ if (input && !key.meta && !key.ctrl) {
130
+ setBuffer((prev) => prev + input);
131
+ }
132
+ }, { isActive: props.inert !== true });
133
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: '? ' }), _jsx(Text, { bold: true, children: 'Plan review - approve before I execute' })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: 'Steps:' }), props.tag.steps.map((step, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: step.text })] }, `step-${idx}`)))] }), props.tag.risk ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: 'Risk:' }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { children: props.tag.risk }) })] })) : null, mode === 'pick' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: ' [a] approve ' }), _jsx(Text, { color: "yellow", bold: true, children: '[m] modify ' }), _jsx(Text, { color: "red", bold: true, children: '[c] cancel' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Press a, m, or c. Esc cancels.' }) })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: 'modify > ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type the revision. Enter submits. Esc cancels.' }) })] }))] }));
134
+ }
135
+ /* ------------------------------------------------------------------ */
136
+ /* Verdict serialisation */
137
+ /* ------------------------------------------------------------------ */
138
+ /**
139
+ * Encode an ask-modal verdict into the literal string the session
140
+ * injects as the next operator turn. The persona's prompt teaches it
141
+ * to recognise this prefix, so the conversation stays coherent without
142
+ * a side channel.
143
+ *
144
+ * Examples:
145
+ * { value: 'vercel' } -> "[ASK-RESPONSE:vercel]"
146
+ * { value: '', customInput: 'gcp'} -> "[ASK-RESPONSE:other] gcp"
147
+ * { cancelled: true } -> "[ASK-RESPONSE:cancelled]"
148
+ *
149
+ * customInput is stripped of any leading `[ASK-RESPONSE:...]` /
150
+ * `[PLAN-VERDICT:...]` pattern so a forged operator prefix cannot be
151
+ * read as a different verdict by a prefix-greedy persona (Claude
152
+ * triple-review P1, PR #375).
153
+ */
154
+ export function encodeAskVerdict(verdict) {
155
+ if (verdict.cancelled)
156
+ return '[ASK-RESPONSE:cancelled]';
157
+ if (verdict.value.length > 0)
158
+ return `[ASK-RESPONSE:${verdict.value}]`;
159
+ const customInput = verdict.customInput
160
+ ? sanitiseVerdictText(verdict.customInput)
161
+ : '';
162
+ if (customInput.length > 0) {
163
+ return `[ASK-RESPONSE:other] ${customInput}`;
164
+ }
165
+ return '[ASK-RESPONSE:cancelled]';
166
+ }
167
+ /**
168
+ * Encode a plan-review verdict into the next operator turn. Mirrors
169
+ * the ask encoding so the persona sees a single grammar.
170
+ *
171
+ * modifyText is sanitised against verdict-header forgery the same way
172
+ * customInput is in encodeAskVerdict (Claude triple-review P1).
173
+ */
174
+ export function encodePlanReviewVerdict(result) {
175
+ switch (result.verdict) {
176
+ case 'approve':
177
+ return '[PLAN-VERDICT:approve]';
178
+ case 'cancel':
179
+ return '[PLAN-VERDICT:cancel]';
180
+ case 'modify': {
181
+ const modifyText = result.modifyText
182
+ ? sanitiseVerdictText(result.modifyText)
183
+ : '';
184
+ if (modifyText.length > 0) {
185
+ return `[PLAN-VERDICT:modify] ${modifyText}`;
186
+ }
187
+ return '[PLAN-VERDICT:cancel]';
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
193
+ * pattern from free-text operator input so a malicious or accidental
194
+ * operator string cannot forge a verdict header. Iterates because the
195
+ * operator could prepend several forged headers in a row.
196
+ *
197
+ * Mirrors the same-named helper in core/repl/session.ts. Kept in both
198
+ * modules so the test surfaces of ask-modal and session are
199
+ * independently exercisable without circular imports.
200
+ */
201
+ function sanitiseVerdictText(raw) {
202
+ let cleaned = raw;
203
+ for (let i = 0; i < raw.length + 4; i += 1) {
204
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
205
+ if (stripped === cleaned)
206
+ break;
207
+ cleaned = stripped;
208
+ }
209
+ return cleaned.trim();
210
+ }
211
+ //# sourceMappingURL=ask-modal.js.map
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { MarkdownRender } from './markdown-render.js';
3
4
  const HUE_COLOR_BY_SLUG = {
4
5
  // Mira (Pug) - coordinator
5
6
  main: 'cyan',
@@ -23,10 +24,14 @@ const HUE_COLOR_BY_SLUG = {
23
24
  analyst: 'gray',
24
25
  };
25
26
  export function ConversationPane(props) {
27
+ const showHeader = props.showHeader !== false;
26
28
  if (props.rows.length === 0) {
27
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Brief the workforce to begin. Try a short sentence or /help." }) }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [showHeader ? _jsx(PaneHeader, { count: 0 }) : null, _jsx(Text, { dimColor: true, children: "Brief the workforce to begin. Try a short sentence or /help." })] }));
28
30
  }
29
- return (_jsx(Box, { flexDirection: "column", children: props.rows.map((row) => (_jsx(ConversationRow, { row: row, personaNames: props.personaNames }, row.id))) }));
31
+ return (_jsxs(Box, { flexDirection: "column", children: [showHeader ? _jsx(PaneHeader, { count: props.rows.length }) : null, props.rows.map((row) => (_jsx(ConversationRow, { row: row, personaNames: props.personaNames }, row.id)))] }));
32
+ }
33
+ function PaneHeader({ count }) {
34
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ conversation ' }), _jsx(Text, { dimColor: true, children: `(${count} row${count === 1 ? '' : 's'})` })] }));
30
35
  }
31
36
  function ConversationRow({ row, personaNames, }) {
32
37
  switch (row.source) {
@@ -38,8 +43,48 @@ function ConversationRow({ row, personaNames, }) {
38
43
  const slug = row.personaSlug ?? '';
39
44
  const color = HUE_COLOR_BY_SLUG[slug] ?? 'white';
40
45
  const displayName = personaNames?.get(slug) ?? slug;
41
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), _jsx(Text, { children: row.text })] }));
46
+ // α6.12: persona bodies travel through MarkdownRender so code
47
+ // fences, headings, and inline accents land correctly. A row that
48
+ // carries no Markdown syntax renders as plain text under the same
49
+ // path (the parser falls through to a single paragraph span), so
50
+ // there is no regression for the simple "Mira shipped." baseline.
51
+ const containsMarkdown = looksLikeMarkdown(row.text);
52
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), containsMarkdown ? null : _jsx(Text, { children: row.text })] }), containsMarkdown ? (_jsx(Box, { marginLeft: 2, children: _jsx(MarkdownRender, { source: row.text }) })) : null] }));
42
53
  }
43
54
  }
44
55
  }
56
+ /**
57
+ * Cheap heuristic for "this transcript row will benefit from Markdown
58
+ * rendering". We only pay the parser cost when the row plausibly
59
+ * contains a code fence, heading, list item, or inline accent. A bare
60
+ * "Mira shipped." line takes the legacy fast path.
61
+ *
62
+ * The probe is intentionally generous - false positives just route
63
+ * through the parser, which renders plain text identically.
64
+ */
65
+ function looksLikeMarkdown(text) {
66
+ if (text.length === 0)
67
+ return false;
68
+ if (text.includes('```'))
69
+ return true;
70
+ // Codex P2 PR #369: intro-plus-list shape ("Summary:\n- bullet")
71
+ // must route through renderer. Scan EVERY line, not just the first.
72
+ const lines = text.split('\n');
73
+ for (const raw of lines) {
74
+ const line = raw.trim();
75
+ if (/^#{1,6}\s/.test(line))
76
+ return true;
77
+ if (/^[-*+]\s/.test(line))
78
+ return true;
79
+ if (/^\d+\.\s/.test(line))
80
+ return true;
81
+ }
82
+ if (/\*\*[^*]+\*\*/.test(text))
83
+ return true;
84
+ if (/`[^`]+`/.test(text))
85
+ return true;
86
+ if (/\[[^\]]+\]\([^)]+\)/.test(text))
87
+ return true;
88
+ return false;
89
+ }
45
90
  //# sourceMappingURL=conversation-pane.js.map