SLOPSHOPPER

codevibez

Shows what your coding agent does in plain English, in place of Claude Code's raw tool rows.

newpanerowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codevibez
│ ┃ codevibez-story ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing has happened yet. Each prompt you │ ┃ type starts a chapter here. ● codevibez: ✗ This turn: changed 1 file, ran 4 commands (1 failed), │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /codevibez │ ⎿ codevibez: Raw view is on, showing exactly what ran in Claude Co │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · codevibez-story
Nothing has happened yet. Each prompt you type starts a chapter here.
README

codevibez

https://github.com/user-attachments/assets/e5145395-1287-4397-814b-cb42ea9760c7

Pretending to read code vs Reading 'code'

A simple idea: make natural language the next coding language (everywhere).

codevibez replaces Claude Code's raw tool-call rows with plain English. Failures, deletions and other risky steps are never softened. Type /codevibez to switch to the raw rows. It makes no model calls and no network requests.

Example

Claude Code shows this:

⏺ Bash(node --test)
  ⎿  TAP version 13
     # Subtest: add
     ok 1 - add
     … +25 lines (ctrl+o to expand)
⏺ Bash(rm -rf build)
  ⎿  Done

With codevibez you see this:

→ Checking everything still works…
  ✓ All checks pass (3 checks)
⚠ Deleting 'build' and everything inside it

More lines from real sessions:

✗ 2 of 3 checks failed
⚠ Deleting 'docs' and everything inside it
⚠ Throwing away every unsaved edit in the project
→ Saving a checkpoint of the work (+1 more step)
✗ Not run: you said no
⏺ codevibez: ✗ This turn: changed 1 file, ran 5 commands (1 failed), all 3 checks passed, 1 destructive step.

Install

Needs Claude Code 2.1.287 or later. In your terminal:

claude plugin install codevibez --marketplace leonhong18/codevibez

To uninstall:

claude plugin uninstall codevibez

Contributing

Found a bug? Open an issue at https://github.com/leonhong18/codevibez/issues with what ran and what you expected it to say.

Source 13 files
hooks/register.tsx 347 lines
1import type { EngineInterface, Register, RenderElement, ResolveInput } from 'claude-code'
2
3import type { Chapter, View } from '../types'
4import { createLedger, observe, summarize } from './ledger.ts'
5import { describeCall, describeGroup, isKnownTool } from './narrate.ts'
6import type { CallView, Narration, Sentence, Tone } from './narrate.ts'
7import { addLines, fitNewest, linesOf, startChapter, storyRows } from './story.ts'
8
9/**
10 * codevibez: plain English in place of Claude Code's raw tool rows.
11 *
12 * Observe only. Every hook here either draws a row or passes the event on
13 * untouched; none of them refuses, rewrites, retries or delays a tool call,
14 * a prompt or a permission decision. Anything unexpected (a tool it does not
15 * know, a row of another shape, a throw) leaves Claude Code's own row.
16 */
17
18const VIEW = { plugin: 'codevibez', key: 'view' } as const
19const STORY = { plugin: 'codevibez', key: 'story' } as const
20const MEMO_LIMIT = 500
21const STORY_SAVE_MS = 1000
22
23const ENGLISH_ON =
24  "English view is on. Type /codevibez to see Claude Code's own rows."
25const RAW_ON =
26  'Raw view is on, showing exactly what ran in Claude Code\'s own rows. Type /codevibez to go back to English.'
27const USAGE =
28  'Use /codevibez to switch views, /codevibez english or /codevibez raw to pick one, or /codevibez story to read the session so far.'
29
30type Look = { color?: string; bold?: boolean; dimColor?: boolean }
31
32// Failures and destructive steps are bold and colored; routine reading is dim.
33const LOOK: Readonly<Record<Tone, Look>> = {
34  info: {},
35  read: { dimColor: true },
36  success: { color: 'success' },
37  failure: { color: 'error', bold: true },
38  danger: { color: 'warning', bold: true },
39  stopped: { color: 'warning' },
40}
41
42async function currentView($: EngineInterface): Promise<View> {
43  const { value } = await $.state.get(VIEW)
44  return value === 'raw' ? 'raw' : 'english'
45}
46
47// The story pane reads the session's state; writing it never holds anything up.
48function saveStory($: EngineInterface, story: Chapter[]): void {
49  $.state.set(STORY, story).catch(() => undefined)
50}
51
52function isRecord(value: unknown): value is Record<string, unknown> {
53  return typeof value === 'object' && value !== null && !Array.isArray(value)
54}
55
56/** A cheap signature of what the translation reads from a call's input. */
57function signature(input: unknown): string {
58  if (!isRecord(input)) return typeof input
59  const field = (name: string): string => {
60    const value = input[name]
61    return typeof value === 'string' ? value : ''
62  }
63  // String lengths, not JSON: a Write carries the whole file, and stringifying
64  // a large one on every draw costs milliseconds.
65  let length = 0
66  for (const value of Object.values(input)) length += typeof value === 'string' ? value.length : 1
67  return [Object.keys(input).length, length, field('command'), field('description'), field('file_path')].join('|')
68}
69
70function asCallView(value: unknown): CallView | null {
71  if (!isRecord(value) || typeof value.tool !== 'string') return null
72  return {
73    tool: value.tool,
74    input: value.input,
75    isRunning: value.isRunning === true,
76    isErrored: value.isErrored === true,
77    isInterrupted: value.isInterrupted === true,
78    output: value.output,
79  }
80}
81
82function draw(
83  $: EngineInterface,
84  e: ResolveInput,
85  lines: readonly Narration[],
86  isRunning: boolean,
87): RenderElement {
88  const { Box, Text } = $.ui.resolve(e)
89  const row = (line: Sentence, key: string, indent: boolean) => {
90    const words = isRunning && !line.text.endsWith('…') ? `${line.text}…` : line.text
91    return (
92      <Box key={key} paddingLeft={indent ? 2 : 0}>
93        <Text {...LOOK[line.tone]}>
94          {line.glyph} {words}
95        </Text>
96      </Box>
97    )
98  }
99  return (
100    <Box flexDirection="column">
101      {lines.flatMap((narration, index) => [
102        row(narration.action, `a${index}`, index > 0),
103        narration.outcome === undefined ? null : row(narration.outcome, `o${index}`, true),
104      ])}
105    </Box>
106  )
107}
108
109export const register: Register = on => {
110  // Narration state lives in memory for the session and is never written down.
111  let ledger = createLedger()
112  let story: Chapter[] = []
113  // The story reaches the session's state only once its pane has been opened,
114  // and then at most once a second while a turn runs, and at the turn's end.
115  let storyShown = false
116  let lastSave = 0
117  const storyDue = (force: boolean): boolean => {
118    if (!storyShown) return false
119    const now = Date.now()
120    if (!force && now - lastSave < STORY_SAVE_MS) return false
121    lastSave = now
122    return true
123  }
124  const memo = new Map<string, Narration>()
125  // Whether each call's row was drawn in English, so its result block below
126  // follows the same choice.
127  const drawnInEnglish = new Map<string, boolean>()
128
129  const narrate = (id: string, view: CallView): Narration | null => {
130    // Claude Code draws a row first with an empty input, then again once the
131    // input has arrived, so what the translation reads is part of the key, and
132    // a row that could not be read yet is never cached.
133    const key = `${id}|${view.isRunning}|${view.isErrored}|${view.isInterrupted}|${view.output === undefined}|${signature(view.input)}`
134    const cached = memo.get(key)
135    if (cached !== undefined) return cached
136    const narration = describeCall(view)
137    if (drawnInEnglish.size >= MEMO_LIMIT) {
138      const oldest = drawnInEnglish.keys().next().value
139      if (oldest !== undefined) drawnInEnglish.delete(oldest)
140    }
141    if (narration === null) {
142      // An input still streaming in says nothing about the finished row.
143      if (isRecord(view.input) && Object.keys(view.input).length > 0) drawnInEnglish.set(id, false)
144      return null
145    }
146    if (memo.size >= MEMO_LIMIT) {
147      const oldest = memo.keys().next().value
148      if (oldest !== undefined) memo.delete(oldest)
149    }
150    memo.set(key, narration)
151    drawnInEnglish.set(id, true)
152    return narration
153  }
154
155  on('session.start', async ($, e, next) => {
156    try {
157      await $.command.register({
158        name: 'codevibez',
159        description: "Switch between plain English and Claude Code's own tool rows",
160        argumentHint: '[english|raw|story]',
161        immediate: true,
162      })
163    } catch {
164      // Without the command the English still draws; nothing to interrupt.
165    }
166    return next(e)
167  })
168
169  on('command.run', { command: 'codevibez' }, async ($, e) => {
170    const asked = e.args.trim().toLowerCase()
171    if (asked === 'story') {
172      storyShown = true
173      lastSave = Date.now()
174      saveStory($, story)
175      const opened = await $.ui.open({ id: 'codevibez-story', title: 'The story so far', focus: true, closeOnEscape: true })
176      return {
177        text: opened.isPlaced
178          ? 'The story of this session is open in a pane. Esc closes it.'
179          : `The story pane could not open here (${opened.reason}).`,
180      }
181    }
182    const now = await currentView($)
183    const wanted: View | null =
184      asked === ''
185        ? now === 'raw' ? 'english' : 'raw'
186        : asked === 'raw'
187          ? 'raw'
188          : asked === 'english' || asked === 'plain'
189            ? 'english'
190            : null
191    if (wanted === null) return { text: USAGE }
192    if (wanted !== now) await $.state.set(VIEW, wanted)
193    return { text: wanted === 'raw' ? RAW_ON : ENGLISH_ON }
194  }).catch(() => ({ text: 'Could not switch views; nothing changed.' }))
195
196  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
197    let tree: RenderElement | undefined
198    try {
199      if ((await currentView($)) === 'english') {
200        const view = asCallView(e.props)
201        const narration = view === null ? null : narrate(e.props.tool_use_id, view)
202        if (view !== null && narration !== null) {
203          tree = draw($, e, [narration], view.isRunning)
204        }
205      }
206    } catch {
207      tree = undefined
208    }
209    return tree ?? next(e)
210  })
211
212  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
213    let tree: RenderElement | undefined
214    try {
215      if ((await currentView($)) === 'english') {
216        // The English row above already says what came of the call, failures
217        // included, so the raw result block under it is left out.
218        const known = drawnInEnglish.get(e.props.tool_use_id) ?? isKnownTool(e.props.tool)
219        if (known) {
220          const { Box } = $.ui.resolve(e)
221          tree = <Box />
222        }
223      }
224    } catch {
225      tree = undefined
226    }
227    return tree ?? next(e)
228  })
229
230  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
231    let tree: RenderElement | undefined
232    try {
233      if (!e.props.isExpanded && (await currentView($)) === 'english') {
234        const calls = e.props.calls.map(asCallView)
235        if (calls.every((call): call is CallView => call !== null)) {
236          const lines = describeGroup(calls)
237          if (lines !== null) tree = draw($, e, lines, false)
238        }
239      }
240    } catch {
241      tree = undefined
242    }
243    return tree ?? next(e)
244  })
245
246  on('ui.render', { component: 'Pane', requestId: 'codevibez-story' }, async ($, e) => {
247    const { Box, Text } = $.ui.resolve(e)
248    try {
249      const { value } = await $.state.get(STORY)
250      const rows = storyRows(value ?? [])
251      if (rows.length === 0) {
252        return <Text dimColor>Nothing has happened yet. Each prompt you type starts a chapter here.</Text>
253      }
254      // The pane opens short, so it shows the newest steps that fit.
255      const { shown, hidden } = fitNewest(rows, e.props.bodyColumns, e.props.scroll.bodyRows)
256      return (
257        <Box flexDirection="column">
258          {hidden > 0 && (
259            <Text key="hidden" dimColor>
260              {hidden} earlier {hidden === 1 ? 'line' : 'lines'} not shown; the newest are below
261            </Text>
262          )}
263          {shown.map((row, index) =>
264            row.kind === 'heading' ? (
265              <Box key={`h${index}`} marginTop={index === 0 ? 0 : 1}>
266                <Text bold>{row.text}</Text>
267              </Box>
268            ) : (
269              <Box key={`l${index}`} paddingLeft={2}>
270                <Text {...(row.kind === 'note' ? LOOK.read : LOOK[row.tone ?? 'info'])}>{row.text}</Text>
271              </Box>
272            ),
273          )}
274        </Box>
275      )
276    } catch {
277      return <Text dimColor>The story could not be drawn just now.</Text>
278    }
279  })
280
281  on('turn.start', ($, e, next) => {
282    ledger = createLedger()
283    try {
284      if (e.text.trim() !== '') {
285        story = startChapter(story, e.text)
286        if (storyDue(false)) saveStory($, story)
287      }
288    } catch {
289      // The story missing a chapter never holds up a prompt.
290    }
291    return next(e)
292  })
293
294  on('tool.call', async ($, e, next) => {
295    const ran = await next(e)
296    try {
297      const { tool, tool_use_id: _id, agentId, ...input } = e as Record<string, unknown>
298      const isError = ran.isError === true
299      const text = typeof ran.text === 'string' ? ran.text : ''
300      observe(ledger, {
301        tool: String(tool),
302        input,
303        isError,
304        isDenied: ran.deny !== undefined,
305        result: ran.result,
306        text,
307      })
308      // The story follows the main conversation; a helper's own steps stay
309      // inside the line that handed it the task.
310      if (agentId === undefined && ran.deny === undefined) {
311        const narration = describeCall({
312          tool: String(tool),
313          input,
314          isRunning: false,
315          isErrored: isError,
316          isInterrupted: false,
317          output: isError ? text || ran.result : ran.result,
318        })
319        if (narration !== null) {
320          story = addLines(story, linesOf(narration))
321          if (storyDue(false)) saveStory($, story)
322        }
323      }
324    } catch {
325      // A summary that misses one call beats a session that stalls on it.
326    }
327    return ran
328  }).catch((_$, e, next) => next(e))
329
330  on('turn.complete', async ($, e, next) => {
331    const ran = await next(e)
332    if (e.agentId !== undefined) return ran
333    try {
334      const summary = summarize(ledger)
335      if (summary !== null) {
336        if ((await currentView($)) === 'english') $.ui.log(`${summary.glyph} ${summary.text}`)
337        story = addLines(story, [summary], true)
338      }
339      if (storyDue(true)) saveStory($, story)
340    } catch {
341      // No summary line is better than a turn that cannot end.
342    }
343    ledger = createLedger()
344    return ran
345  })
346}
347
hooks/ledger.ts 190 lines
1import { detectDanger } from '../engine/src/bash.ts';
2import { isTestCommand } from '../engine/src/rules/bash-rules.ts';
3import { parseTestCounts } from '../engine/src/rules/outcome-rules.ts';
4import { counted, plural } from '../engine/src/words.ts';
5import { isDeclined, isHookBlocked } from './narrate.ts';
6
7/**
8 * What one turn changed, kept in memory only and counted exactly, so the line
9 * under the answer can say it in plain words. Nothing here touches the disk.
10 */
11export type Ledger = {
12  created: Set<string>;
13  changed: Set<string>;
14  deleted: Set<string>;
15  commands: number;
16  failedCommands: number;
17  failedSteps: number;
18  declined: number;
19  hookBlocked: number;
20  destructive: number;
21  tests: TestRun | null;
22};
23
24type TestRun =
25  | { kind: 'counted'; failed: number; passed: number }
26  | { kind: 'failed' }
27  | { kind: 'passed' };
28
29/** A finished call as `tool.call` saw it: the tool, its input, its result. */
30export type FinishedCall = {
31  tool: string;
32  input: Record<string, unknown>;
33  isError: boolean;
34  isDenied: boolean;
35  result: unknown;
36  text: string;
37};
38
39export function createLedger(): Ledger {
40  return {
41    created: new Set(),
42    changed: new Set(),
43    deleted: new Set(),
44    commands: 0,
45    failedCommands: 0,
46    failedSteps: 0,
47    declined: 0,
48    hookBlocked: 0,
49    destructive: 0,
50    tests: null,
51  };
52}
53
54function isRecord(value: unknown): value is Record<string, unknown> {
55  return typeof value === 'object' && value !== null && !Array.isArray(value);
56}
57
58function touched(ledger: Ledger, path: unknown): void {
59  if (typeof path !== 'string' || path === '') return;
60  if (!ledger.created.has(path)) ledger.changed.add(path);
61}
62
63function testRun(output: string, ok: boolean): TestRun {
64  const counts = parseTestCounts(output);
65  if (counts !== null) return { kind: 'counted', ...counts };
66  return ok ? { kind: 'passed' } : { kind: 'failed' };
67}
68
69/** Records one finished call. Never throws on a shape it does not expect. */
70export function observe(ledger: Ledger, call: FinishedCall): void {
71  if (call.isDenied) return;
72  const { tool, input, result } = call;
73  // A step the person said no to never ran: not a command, not a failure.
74  if (call.isError && isDeclined(call.text || (typeof result === 'string' ? result : ''))) {
75    ledger.declined += 1;
76    return;
77  }
78  // A step a settings hook stopped never ran either.
79  if (call.isError && isHookBlocked(call.text || (typeof result === 'string' ? result : ''))) {
80    ledger.hookBlocked += 1;
81    return;
82  }
83
84  if (tool === 'Bash') {
85    const command = typeof input.command === 'string' ? input.command : '';
86    ledger.commands += 1;
87    if (call.isError) ledger.failedCommands += 1;
88    if (command !== '' && detectDanger(command) !== null) ledger.destructive += 1;
89    if (command !== '' && isTestCommand(command)) {
90      const output = isRecord(result)
91        ? `${typeof result.stdout === 'string' ? result.stdout : ''}\n${
92            typeof result.stderr === 'string' ? result.stderr : ''
93          }`
94        : call.text;
95      ledger.tests = testRun(output, !call.isError);
96    }
97    const diff = isRecord(result) ? result.bashEditDiff : undefined;
98    if (isRecord(diff) && Array.isArray(diff.files)) {
99      for (const file of diff.files) {
100        if (!isRecord(file) || typeof file.filePath !== 'string') continue;
101        if (file.deleted === true) ledger.deleted.add(file.filePath);
102        else if (file.created === true) ledger.created.add(file.filePath);
103        else touched(ledger, file.filePath);
104      }
105    }
106    return;
107  }
108
109  if (call.isError) {
110    ledger.failedSteps += 1;
111    return;
112  }
113
114  if (tool === 'Write' && isRecord(result)) {
115    const path = typeof result.filePath === 'string' ? result.filePath : input.file_path;
116    if (result.type === 'create' && typeof path === 'string') ledger.created.add(path);
117    else touched(ledger, path);
118  } else if (tool === 'Edit' || tool === 'MultiEdit') {
119    touched(ledger, input.file_path);
120  } else if (tool === 'NotebookEdit') {
121    touched(ledger, input.notebook_path);
122    if (input.edit_mode === 'delete') ledger.destructive += 1;
123  }
124}
125
126function testWords(run: TestRun): string {
127  if (run.kind === 'counted') {
128    const total = run.failed + run.passed;
129    return run.failed > 0
130      ? `${run.failed} of ${total} ${plural(total, 'check')} failed`
131      : `all ${counted(run.passed, 'check')} passed`;
132  }
133  return run.kind === 'failed'
134    ? 'the checks failed (codevibez could not read how many)'
135    : 'the checks passed';
136}
137
138export type Summary = {
139  glyph: string;
140  text: string;
141  tone: 'info' | 'failure' | 'danger';
142};
143
144/**
145 * One plain line for the turn, or null when nothing changed and nothing ran.
146 */
147export function summarize(ledger: Ledger): Summary | null {
148  const parts: string[] = [];
149  const files = ledger.created.size + ledger.changed.size;
150  if (files > 0) {
151    parts.push(
152      ledger.created.size > 0
153        ? `changed ${counted(files, 'file')} (${ledger.created.size} new)`
154        : `changed ${counted(files, 'file')}`,
155    );
156  }
157  if (ledger.deleted.size > 0) parts.push(`deleted ${counted(ledger.deleted.size, 'file')}`);
158  if (ledger.commands > 0) {
159    parts.push(
160      ledger.failedCommands > 0
161        ? `ran ${counted(ledger.commands, 'command')} (${ledger.failedCommands} failed)`
162        : `ran ${counted(ledger.commands, 'command')}`,
163    );
164  }
165  if (ledger.tests !== null) parts.push(testWords(ledger.tests));
166  if (ledger.failedSteps > 0) {
167    parts.push(`${counted(ledger.failedSteps, 'other step')} failed`);
168  }
169  if (ledger.destructive > 0) {
170    parts.push(`${counted(ledger.destructive, 'destructive step')}`);
171  }
172  if (ledger.declined > 0) {
173    parts.push(`you said no to ${counted(ledger.declined, 'step')}`);
174  }
175  if (ledger.hookBlocked > 0) {
176    parts.push(`a hook blocked ${counted(ledger.hookBlocked, 'step')}`);
177  }
178  if (parts.length === 0) return null;
179
180  const failed =
181    ledger.failedCommands > 0 ||
182    ledger.failedSteps > 0 ||
183    (ledger.tests?.kind === 'counted' && ledger.tests.failed > 0) ||
184    ledger.tests?.kind === 'failed';
185  const text = `This turn: ${parts.join(', ')}.`;
186  if (failed) return { glyph: '✗', text, tone: 'failure' };
187  if (ledger.destructive > 0) return { glyph: '⚠', text, tone: 'danger' };
188  return { glyph: '●', text, tone: 'info' };
189}
190
hooks/narrate.ts 598 lines
1import { formatPrompt } from '../engine/src/prompt.ts';
2import { translateBash } from '../engine/src/rules/bash-rules.ts';
3import { translateFileTool } from '../engine/src/rules/file-rules.ts';
4import { translateOutcome } from '../engine/src/rules/outcome-rules.ts';
5import type { CodevibezEvent, Level, Line } from '../engine/src/types.ts';
6import { counted, plural } from '../engine/src/words.ts';
7import { redactSecrets } from './redact.ts';
8
9/**
10 * Turns one tool call, as a transcript row sees it, into plain English.
11 *
12 * Pure functions only: no `$`, no clock, no disk. The same row in gives the
13 * same sentence out, which is what lets the hooks memoize them per call.
14 */
15
16const LEVEL: Level = 'plain';
17
18export type Tone = 'info' | 'read' | 'success' | 'failure' | 'danger' | 'stopped';
19
20export type Sentence = {
21  glyph: string;
22  text: string;
23  tone: Tone;
24  source?: 'description';
25};
26
27export type Narration = {
28  /** What the call does. */
29  action: Sentence;
30  /** What came of it, when there is something worth saying. */
31  outcome?: Sentence;
32};
33
34/** A tool call as the `ToolUse` row and each call of a `ToolGroup` carry it. */
35export type CallView = {
36  tool: string;
37  input: unknown;
38  isRunning: boolean;
39  isErrored: boolean;
40  isInterrupted: boolean;
41  output?: unknown;
42};
43
44type Input = Record<string, unknown>;
45
46function isRecord(value: unknown): value is Input {
47  return typeof value === 'object' && value !== null && !Array.isArray(value);
48}
49
50function text(value: unknown): string | null {
51  return typeof value === 'string' && value.trim() !== '' ? value : null;
52}
53
54function quoted(value: string, limit = 60): string {
55  return `"${formatPrompt(value, limit)}"`;
56}
57
58function sentence(glyph: string, words: string, tone: Tone): Sentence {
59  return { glyph, text: redactSecrets(words), tone };
60}
61
62function fromLine(line: Line): Sentence {
63  const tone: Tone =
64    line.severity === 'danger'
65      ? 'danger'
66      : line.severity === 'failure'
67        ? 'failure'
68        : line.severity === 'success'
69          ? 'success'
70          : line.tier === 'read'
71            ? 'read'
72            : 'info';
73  return sentence(line.glyph, line.text, tone);
74}
75
76function plainDescription(said: string): string {
77  const one = formatPrompt(said.replace(/[.\s]+$/, ''), 80);
78  return one.charAt(0).toUpperCase() + one.slice(1);
79}
80
81function hostOf(url: string): string | null {
82  try {
83    return new URL(url).hostname || null;
84  } catch {
85    return null;
86  }
87}
88
89function serviceOf(tool: string): { service: string; action: string } | null {
90  const match = /^mcp__(.+?)__(.+)$/.exec(tool);
91  if (match === null) return null;
92  const service = (match[1] ?? '').replace(/^plugin_[^_]+_/, '').replace(/[_-]+/g, ' ');
93  const action = (match[2] ?? '').replace(/[_-]+/g, ' ');
94  return service === '' || action === '' ? null : { service, action };
95}
96
97type Todo = { status?: unknown };
98
99function todoCounts(todos: readonly Todo[]): string {
100  const done = todos.filter((todo) => todo.status === 'completed').length;
101  return `(${done} of ${counted(todos.length, 'item')} done)`;
102}
103
104/** What the call does, or null when the call is not one this mod knows. */
105function describeAction(view: CallView): Sentence | null {
106  const { tool, input } = view;
107  if (!isRecord(input)) return null;
108
109  switch (tool) {
110    case 'Bash': {
111      const command = text(input.command);
112      if (command === null) return null;
113      const rule = translateBash(command, LEVEL);
114      // A routine step the rules cannot name reads as the step's own
115      // description. Destructive steps (their own ids) and failed or stopped
116      // calls never take this path: they keep the rule-based line.
117      const said = text(input.description);
118      const line =
119        rule.id === 'bash.generic' && said !== null && !view.isErrored && !view.isInterrupted
120          ? { ...sentence('→', plainDescription(said), rule.tier === 'read' ? 'read' : 'info'), source: 'description' as const }
121          : fromLine(rule);
122      return input.run_in_background === true
123        ? { ...line, text: `${line.text} (in the background)` }
124        : line;
125    }
126
127    case 'Read':
128    case 'Edit':
129    case 'MultiEdit': {
130      if (text(input.file_path) === null) return null;
131      const line = translateFileTool(tool, input, LEVEL);
132      return line === null ? null : fromLine(line);
133    }
134
135    case 'Grep':
136    case 'Glob': {
137      if (text(input.pattern) === null) return null;
138      const line = translateFileTool(tool, input, LEVEL);
139      return line === null ? null : fromLine(line);
140    }
141
142    case 'Write': {
143      if (text(input.file_path) === null) return null;
144      // Whether this creates a file or replaces one is only known once it ran.
145      const kind = isRecord(view.output) ? view.output.type : undefined;
146      if (kind !== 'create' && kind !== 'update') {
147        return sentence('✎', 'Writing code to a file', 'info');
148      }
149      const line = translateFileTool(
150        'Write',
151        { ...input, codevibez_exists: kind === 'update' },
152        LEVEL,
153      );
154      return line === null ? null : fromLine(line);
155    }
156
157    case 'NotebookEdit': {
158      if (text(input.notebook_path) === null) return null;
159      if (input.edit_mode === 'delete') {
160        return sentence('⚠', 'Deleting a section of a notebook', 'danger');
161      }
162      return input.edit_mode === 'insert'
163        ? sentence('✎', 'Adding a section to a notebook', 'info')
164        : sentence('✎', 'Updating a section of a notebook', 'info');
165    }
166
167    case 'WebFetch': {
168      const url = text(input.url);
169      if (url === null) return null;
170      const host = hostOf(url);
171      return sentence(
172        '→',
173        host === null ? 'Reading a web page' : `Reading a web page (${host})`,
174        'read',
175      );
176    }
177
178    case 'WebSearch': {
179      const query = text(input.query);
180      if (query === null) return null;
181      return sentence('→', `Searching the web for ${quoted(query)}`, 'read');
182    }
183
184    case 'Agent':
185    case 'Task': {
186      const task = text(input.description) ?? text(input.prompt);
187      if (task === null) return null;
188      return sentence('→', `Handing a task to a helper: ${quoted(task)}`, 'info');
189    }
190
191    case 'TodoWrite': {
192      if (!Array.isArray(input.todos)) return null;
193      const todos = input.todos.filter(isRecord);
194      return sentence('→', `Updating its to-do list ${todoCounts(todos)}`, 'read');
195    }
196
197    case 'TaskCreate': {
198      const subject = text(input.subject);
199      if (subject === null) return null;
200      return sentence('→', `Adding a to-do: ${quoted(subject)}`, 'read');
201    }
202
203    case 'TaskUpdate': {
204      if (text(input.taskId) === null) return null;
205      const words =
206        input.status === 'completed'
207          ? 'Marking a to-do as done'
208          : input.status === 'in_progress'
209            ? 'Starting on a to-do'
210            : input.status === 'deleted'
211              ? 'Removing a to-do'
212              : 'Updating a to-do';
213      return sentence('→', words, 'read');
214    }
215
216    case 'TaskList':
217    case 'TaskGet':
218      return sentence('→', 'Checking its to-do list', 'read');
219
220    case 'TaskStop':
221      return sentence('→', 'Stopping a task running in the background', 'info');
222
223    case 'Skill': {
224      const skill = text(input.skill);
225      if (skill === null) return null;
226      return sentence('→', `Following a saved set of instructions (${skill})`, 'info');
227    }
228
229    case 'ToolSearch':
230      return sentence('→', 'Looking for the right tool for the job', 'read');
231
232    case 'AskUserQuestion':
233      return sentence('→', 'Asking you a question', 'info');
234
235    case 'EnterPlanMode':
236      return sentence('→', 'Making a plan before changing anything', 'info');
237
238    case 'ExitPlanMode':
239      return sentence('→', 'Showing you its plan for approval', 'info');
240
241    case 'LSP':
242      return sentence('→', 'Checking how the code fits together', 'read');
243
244    case 'Monitor':
245      return sentence('→', 'Keeping an eye on something in the background', 'info');
246
247    case 'SendMessage':
248      return sentence('→', 'Sending a message to a helper', 'info');
249    case 'ScheduleWakeup':
250      return sentence('→', 'Setting a reminder to check back later', 'info');
251    case 'CronCreate':
252      return sentence('→', 'Scheduling a job to repeat', 'info');
253    case 'CronDelete':
254      return sentence('→', 'Cancelling a scheduled job', 'info');
255    case 'CronList':
256      return sentence('→', 'Checking its scheduled jobs', 'read');
257    case 'EnterWorktree':
258      return sentence('→', 'Starting work in a separate copy of the project', 'info');
259    case 'ExitWorktree':
260      return input.action === 'remove'
261        ? sentence('⚠', input.discard_changes === true
262            ? 'Removing the separate copy of the project and throwing away its unsaved edits'
263            : 'Removing the separate copy of the project', 'danger')
264        : sentence('→', 'Leaving the separate copy of the project', 'info');
265    case 'Workflow':
266      return sentence('→', 'Running a planned set of helper steps', 'info');
267    case 'ListMcpResourcesTool':
268    case 'ReadMcpResourceDirTool':
269      return sentence('→', 'Looking at what a connected service offers', 'read');
270    case 'ReadMcpResourceTool':
271      return sentence('→', 'Reading from a connected service', 'read');
272    case 'PushNotification':
273      return sentence('→', 'Sending you a notification', 'info');
274    case 'SendUserMessage':
275      return sentence('→', 'Sending you a message', 'info');
276    case 'SendUserFile':
277    case 'SendFile':
278      return sentence('→', 'Sending you a file', 'info');
279    case 'ListAgents':
280      return sentence('→', 'Checking which helpers are running', 'read');
281    case 'memory_read':
282    case 'memory_list':
283      return sentence('→', 'Looking at its saved notes', 'read');
284    case 'memory_write':
285      return sentence('✎', 'Saving a note for later', 'info');
286
287    default: {
288      const mcp = serviceOf(tool);
289      if (mcp === null) {
290        // Any other tool still gets a plain line; its exact name is kept.
291        return /^[A-Za-z][\w-]*$/.test(tool)
292          ? sentence('→', `Using one of Claude Code's tools (${tool.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_+/g, ' ').trim().toLowerCase()})`, 'info')
293          : null;
294      }
295      return sentence(
296        '→',
297        `Using the connected service "${mcp.service}" (${mcp.action})`,
298        'info',
299      );
300    }
301  }
302}
303
304const KNOWN_TOOLS = new Set([
305  'Bash', 'Read', 'Edit', 'MultiEdit', 'Grep', 'Glob', 'Write', 'NotebookEdit',
306  'WebFetch', 'WebSearch', 'Agent', 'Task', 'TodoWrite', 'TaskCreate', 'TaskUpdate',
307  'TaskList', 'TaskGet', 'TaskStop', 'Skill', 'ToolSearch', 'AskUserQuestion',
308  'EnterPlanMode', 'ExitPlanMode', 'LSP', 'Monitor', 'SendMessage', 'ScheduleWakeup',
309  'CronCreate', 'CronDelete', 'CronList', 'EnterWorktree', 'ExitWorktree', 'Workflow',
310  'ListMcpResourcesTool', 'ReadMcpResourceDirTool', 'ReadMcpResourceTool', 'PushNotification',
311  'SendUserMessage', 'SendUserFile', 'SendFile', 'ListAgents', 'memory_read', 'memory_list', 'memory_write',
312]);
313
314/** Whether this mod has English for the tool at all, judged by name alone. */
315export function isKnownTool(tool: string): boolean {
316  return KNOWN_TOOLS.has(tool) || serviceOf(tool) !== null;
317}
318
319/** The text a failed call handed back, whatever shape it came in. */
320function errorText(output: unknown): string {
321  if (typeof output === 'string') return output;
322  if (isRecord(output)) {
323    for (const key of ['error', 'stderr', 'message', 'text']) {
324      const value = output[key];
325      if (typeof value === 'string' && value !== '') return value;
326    }
327  }
328  if (Array.isArray(output)) {
329    return output
330      .map((block) => (isRecord(block) && typeof block.text === 'string' ? block.text : ''))
331      .join('\n');
332  }
333  return '';
334}
335
336function plainReason(error: string): string | null {
337  if (/string to replace not found|old_string/i.test(error)) {
338    return 'the text it meant to change was not there';
339  }
340  if (/has not been read|read it first/i.test(error)) {
341    return 'it had not looked at the file first';
342  }
343  if (/does not exist|no such file|not found|enoent\b|cannot find/i.test(error)) {
344    return 'something it needed could not be found';
345  }
346  if (/permission denied|eacces\b|eperm\b|not permitted/i.test(error)) {
347    return 'it was not allowed to';
348  }
349  if (/timed? ?out\b|timeout/i.test(error)) return 'it took too long';
350  if (/econnrefused\b|enotfound\b|network|fetch failed|unreachable/i.test(error)) {
351    return 'the network could not be reached';
352  }
353  return null;
354}
355
356const FAILED_STEP: Readonly<Record<string, string>> = {
357  Read: 'Could not open the file',
358  Edit: 'Could not change the file',
359  MultiEdit: 'Could not change the file',
360  Write: 'Could not save the file',
361  NotebookEdit: 'Could not change the notebook',
362  Grep: 'The search failed',
363  Glob: 'The search failed',
364  WebFetch: 'Could not read the web page',
365  WebSearch: 'The web search failed',
366  Agent: 'The helper could not finish its task',
367  Task: 'The helper could not finish its task',
368};
369
370function bashFailure(command: string, error: string): Sentence {
371  // Claude Code reports a non-zero exit as its own line, `Exit code 1`,
372  // sometimes led by `Error:` or wrapped in a tag, ahead of the output.
373  const exit = /(?:^|\n)[^\S\n]*(?:<[^>\n]+>)?[^\S\n]*(?:Error:[^\S\n]*)?Exit code (\d+)[^\S\n]*(?:\n|$)/.exec(error);
374  const exitCode = exit === null ? undefined : Number(exit[1]);
375  const output = exit === null ? error : error.replace(exit[0], '\n');
376  const event: CodevibezEvent = {
377    v: 1,
378    ts: '',
379    session: '',
380    agent: 'claude-code',
381    kind: 'tool.post',
382    tool: 'Bash',
383    raw: { command },
384    outcome: {
385      ok: false,
386      ...(exitCode === undefined ? {} : { exitCode }),
387      stdout: output,
388      stderr: '',
389    },
390  };
391  const line = translateOutcome(event, LEVEL);
392  if (line === null) {
393    return sentence('✗', 'The command failed', 'failure');
394  }
395  // A test run's own line carries the exact counts; anything else gets the
396  // exit code, which is the one number every failed command has.
397  if (line.id === 'out.fail' && exitCode !== undefined) {
398    return sentence('✗', `${line.stem ?? line.text} (exit code ${exitCode})`, 'failure');
399  }
400  return fromLine(line);
401}
402
403function describeFailure(view: CallView): Sentence {
404  const error = errorText(view.output);
405  if (/has been denied|permission to use .* (?:was|has been) denied/i.test(error)) {
406    return sentence('✗', 'Not run: a permission rule blocked it', 'failure');
407  }
408  if (view.tool === 'Bash' && isRecord(view.input) && typeof view.input.command === 'string') {
409    return bashFailure(view.input.command, error);
410  }
411  const step = FAILED_STEP[view.tool] ?? (serviceOf(view.tool) === null
412    ? 'That step failed'
413    : 'The connected service reported a failure');
414  const reason = plainReason(error);
415  const status = /status code (\d{3})/i.exec(error)?.[1];
416  const words = [step, reason === null ? '' : `: ${reason}`, status === undefined ? '' : ` (error ${status})`].join('');
417  return sentence('✗', words, 'failure');
418}
419
420/** What came of a finished call that did not fail, when it is worth a line. */
421function describeSuccess(view: CallView): Sentence | undefined {
422  const { tool, input, output } = view;
423  if (tool === 'Bash' && isRecord(input) && typeof input.command === 'string' && isRecord(output)) {
424    const event: CodevibezEvent = {
425      v: 1,
426      ts: '',
427      session: '',
428      agent: 'claude-code',
429      kind: 'tool.post',
430      tool: 'Bash',
431      raw: { command: input.command },
432      outcome: {
433        ok: true,
434        stdout: typeof output.stdout === 'string' ? output.stdout : '',
435        stderr: typeof output.stderr === 'string' ? output.stderr : '',
436      },
437    };
438    const line = translateOutcome(event, LEVEL);
439    return line === null ? undefined : fromLine(line);
440  }
441  if (tool === 'WebFetch' && isRecord(output) && typeof output.code === 'number' && output.code >= 400) {
442    const status = typeof output.codeText === 'string' && output.codeText !== ''
443      ? `${output.code} ${output.codeText}`
444      : String(output.code);
445    return sentence('✗', `The web page answered with an error (${status})`, 'failure');
446  }
447  return undefined;
448}
449
450const DECLINED = /^(?:Error:\s*)?(?:The user doesn't want to proceed|User rejected)|tool use was rejected/i;
451
452/**
453 * The first line of what came back. Claude Code's own notices start there, so
454 * a failing step whose output merely mentions one is still a failure.
455 */
456function firstLine(text: string): string {
457  return text.trimStart().split('\n', 1)[0] ?? '';
458}
459
460/** Whether the person said no to the call at a permission prompt. */
461export function isDeclined(text: string): boolean {
462  return DECLINED.test(firstLine(text));
463}
464
465// What Claude Code reports when a PreToolUse hook stops a call, whether the
466// hook exits 2 or answers with a JSON deny: `PreToolUse:Bash hook error: …`.
467const HOOK_BLOCKED = /^(?:Error:\s*)?PreToolUse(?::[\w-]+)? hook error:/;
468
469/** Whether a settings hook stopped the call before it ran. */
470export function isHookBlocked(text: string): boolean {
471  return HOOK_BLOCKED.test(firstLine(text));
472}
473
474const NOT_RUN_DECLINED = 'Not run: you said no';
475const NOT_RUN_HOOK = 'Not run: a hook blocked it';
476
477/**
478 * The English for one call, or null when this mod does not know the tool or
479 * the row's shape, so Claude Code draws its own row.
480 */
481export function describeCall(view: CallView): Narration | null {
482  const action = describeAction(view);
483  if (action === null) return null;
484  if (view.isRunning) return { action };
485  if ((view.isErrored || view.isInterrupted) && isDeclined(errorText(view.output))) {
486    return { action, outcome: sentence('✗', NOT_RUN_DECLINED, 'stopped') };
487  }
488  if ((view.isErrored || view.isInterrupted) && isHookBlocked(errorText(view.output))) {
489    return { action, outcome: sentence('✗', NOT_RUN_HOOK, 'stopped') };
490  }
491  if (view.isInterrupted) {
492    return { action, outcome: sentence('■', 'Stopped before it finished', 'stopped') };
493  }
494  if (view.isErrored) return { action, outcome: describeFailure(view) };
495  const outcome = describeSuccess(view);
496  return outcome === undefined ? { action } : { action, outcome };
497}
498
499type GroupCall = CallView;
500
501const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'WebSearch', 'ToolSearch']);
502const TODO_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet']);
503
504/**
505 * One line for a run of calls Claude Code folds together (reads, searches,
506 * listings), with every failure and every destructive step spelled out.
507 */
508export function describeGroup(calls: readonly GroupCall[]): Narration[] | null {
509  if (calls.length === 0) return null;
510  const described = calls.map((call) => ({ call, narration: describeCall(call) }));
511  if (described.some((item) => item.narration === null)) return null;
512  // A group of one is that call's own line.
513  const only = described.length === 1 ? described[0]?.narration : undefined;
514  if (only !== undefined && only !== null) return [only];
515
516  let files = 0;
517  let searches = 0;
518  let commands = 0;
519  let writes = 0;
520  let pages = 0;
521  let todos = 0;
522  let other = 0;
523  for (const { call, narration } of described) {
524    if (call.tool === 'Read') files += 1;
525    else if (TODO_TOOLS.has(call.tool)) todos += 1;
526    else if (SEARCH_TOOLS.has(call.tool)) searches += 1;
527    // Claude Code folds `cat > f <<EOF` in with reads; it still writes a file.
528    else if (call.tool === 'Bash' && narration?.action.glyph === '✎') writes += 1;
529    else if (call.tool === 'Bash') commands += 1;
530    else if (call.tool === 'WebFetch') pages += 1;
531    else other += 1;
532  }
533  const parts = [
534    files > 0 ? `${counted(files, 'file')} read` : '',
535    searches > 0 ? counted(searches, 'search', 'searches') : '',
536    commands > 0 ? counted(commands, 'command') : '',
537    writes > 0 ? counted(writes, 'file write') : '',
538    pages > 0 ? `${counted(pages, 'web page')} read` : '',
539    todos > 0 ? counted(todos, 'to-do update') : '',
540    other > 0 ? counted(other, 'other step') : '',
541  ].filter((part) => part !== '');
542  const stem =
543    todos === described.length
544      ? 'Updating its to-do list'
545      : writes === described.length
546        ? 'Writing files'
547        : writes > 0
548          ? 'Looking around and writing files'
549          : commands === described.length
550            ? 'Looking around the project'
551            : 'Getting familiar with your code';
552
553  const lines: Narration[] = [
554    { action: writes > 0 ? sentence('✎', `${stem} (${parts.join(', ')})`, 'info') : sentence('→', `${stem} (${parts.join(', ')})`, 'read') },
555  ];
556
557  // Anything that is not routine is never folded into the count line.
558  for (const { narration } of described) {
559    if (narration === null) continue;
560    if (narration.action.tone === 'danger') lines.push({ action: narration.action });
561  }
562  const failed = described.filter(
563    (item) => item.narration?.outcome?.tone === 'failure',
564  );
565  if (failed.length > 0) {
566    const first = failed[0]?.narration?.outcome;
567    const total = described.length;
568    const head = `${failed.length} of ${total} ${plural(total, 'step')} failed`;
569    lines.push({
570      action: sentence(
571        '✗',
572        failed.length === 1 && first !== undefined ? `${head}: ${first.text}` : head,
573        'failure',
574      ),
575    });
576  }
577  const declined = described.filter((item) => item.narration?.outcome?.text === NOT_RUN_DECLINED).length;
578  if (declined > 0) {
579    lines.push({ action: sentence('✗', `Not run: you said no to ${counted(declined, 'step')}`, 'stopped') });
580  }
581  const hooked = described.filter((item) => item.narration?.outcome?.text === NOT_RUN_HOOK).length;
582  if (hooked > 0) {
583    lines.push({ action: sentence('✗', `Not run: a hook blocked ${counted(hooked, 'step')}`, 'stopped') });
584  }
585  const stopped = described.filter(
586    (item) =>
587      item.narration?.outcome?.tone === 'stopped' &&
588      item.narration.outcome.text !== NOT_RUN_DECLINED &&
589      item.narration.outcome.text !== NOT_RUN_HOOK,
590  ).length;
591  if (stopped > 0) {
592    lines.push({
593      action: sentence('■', `${counted(stopped, 'step')} stopped before finishing`, 'stopped'),
594    });
595  }
596  return lines;
597}
598
hooks/story.ts 120 lines
1import type { Chapter, StoryLine } from '../types';
2import type { Narration } from './narrate.ts';
3import { redactSecrets } from './redact.ts';
4
5/**
6 * The session told in plain English: one chapter per prompt you typed, its
7 * steps in order, and the turn's summary line. Pure functions over plain data,
8 * kept in the session's memory only.
9 */
10
11const MAX_CHAPTERS = 50;
12const MAX_LINES = 200;
13
14export function startChapter(story: readonly Chapter[], prompt: string): Chapter[] {
15  const chapter: Chapter = { prompt: redactSecrets(prompt), lines: [], more: 0 };
16  return [...story, chapter].slice(-MAX_CHAPTERS);
17}
18
19function lastChapter(story: readonly Chapter[]): Chapter[] {
20  return story.length > 0 ? [...story] : [{ prompt: '', lines: [], more: 0 }];
21}
22
23const ALWAYS_KEPT: ReadonlySet<StoryLine['tone']> = new Set(['failure', 'danger']);
24const MAX_SERIOUS = 300;
25
26/**
27 * Adds lines to the newest chapter. Past the chapter's room, routine lines
28 * are counted exactly instead of kept. Failures and destructive steps get 300
29 * more lines of room, then are counted exactly too, so a long session cannot
30 * grow the story without bound; the turn's summary is always kept.
31 */
32export function addLines(
33  story: readonly Chapter[],
34  lines: readonly StoryLine[],
35  isSummary = false,
36): Chapter[] {
37  if (lines.length === 0) return [...story];
38  const next = lastChapter(story);
39  const index = next.length - 1;
40  const chapter = next[index];
41  if (chapter === undefined) return next;
42  const kept = [...chapter.lines];
43  let more = chapter.more;
44  let serious = chapter.serious ?? 0;
45  for (const line of lines) {
46    const isSerious = ALWAYS_KEPT.has(line.tone);
47    if (isSummary || kept.length < MAX_LINES || (isSerious && kept.length < MAX_LINES + MAX_SERIOUS)) kept.push(line);
48    else {
49      more += 1;
50      if (isSerious) serious += 1;
51    }
52  }
53  next[index] = { ...chapter, lines: kept, more, ...(serious > 0 ? { serious } : {}) };
54  return next;
55}
56
57export function linesOf(narration: Narration): StoryLine[] {
58  return narration.outcome === undefined
59    ? [narration.action]
60    : [narration.action, narration.outcome];
61}
62
63export type StoryRow = { kind: 'heading' | 'line' | 'note'; text: string; tone?: StoryLine['tone'] };
64
65/** How many terminal lines a row takes at this width, wrapped. */
66function heightOf(row: StoryRow, columns: number): number {
67  const room = Math.max(10, columns - (row.kind === 'heading' ? 0 : 2));
68  return Math.max(1, Math.ceil(row.text.length / room)) + (row.kind === 'heading' ? 1 : 0);
69}
70
71/**
72 * The newest rows that fit the pane, and how many earlier rows are left out,
73 * so the latest step is always in view.
74 */
75export function fitNewest(
76  rows: readonly StoryRow[],
77  columns: number,
78  height: number,
79): { shown: StoryRow[]; hidden: number } {
80  let used = 0;
81  let start = rows.length;
82  while (start > 0) {
83    const next = rows[start - 1];
84    if (next === undefined) break;
85    const cost = heightOf(next, columns);
86    // Keep one line free to say how much is above, unless everything fits.
87    if (used + cost > height - (start > 1 ? 1 : 0) && start < rows.length) break;
88    used += cost;
89    start -= 1;
90  }
91  return { shown: rows.slice(start), hidden: start };
92}
93
94/** The story as rows to draw, oldest first. */
95export function storyRows(story: readonly Chapter[]): StoryRow[] {
96  const rows: StoryRow[] = [];
97  for (const chapter of story) {
98    rows.push({
99      kind: 'heading',
100      text: chapter.prompt === '' ? 'Before your first prompt' : chapter.prompt,
101    });
102    if (chapter.lines.length === 0 && chapter.more === 0) {
103      rows.push({ kind: 'note', text: 'No steps.' });
104    }
105    for (const line of chapter.lines) {
106      rows.push({ kind: 'line', text: `${line.glyph} ${line.text}`, tone: line.tone });
107    }
108    if (chapter.more > 0) {
109      rows.push({
110        kind: 'note',
111        text:
112          (chapter.serious ?? 0) > 0
113            ? `and ${chapter.more} more ${chapter.more === 1 ? 'line' : 'lines'} not shown here, ${chapter.serious} of them failures or destructive steps`
114            : `and ${chapter.more} more routine ${chapter.more === 1 ? 'line' : 'lines'} not shown here`,
115      });
116    }
117  }
118  return rows;
119}
120
engine/src/bash.ts 935 lines
1/**
2 * Shell text in, danger verdict out.
3 *
4 * Two properties matter more than coverage here, and they pull in opposite
5 * directions from ordinary parsing:
6 *
7 * 1. A missed destructive command is the worst possible failure. codevibez's
8 *    whole claim is that you can trust the feed, so silence about a deletion
9 *    is worse than a false alarm. Where a heuristic could go either way, it
10 *    goes toward warning.
11 * 2. The most severe hit in a command wins. Returning the first match found
12 *    let a harmless `> /dev/null` on the end of a line hide the `rm -rf` at
13 *    the start of it.
14 */
15
16export function splitSegments(command: string): string[] {
17  const segments: string[] = [];
18  let current = '';
19  let quote: '"' | "'" | null = null;
20
21  for (let index = 0; index < command.length; index += 1) {
22    const character = command.charAt(index);
23    const next = command.charAt(index + 1);
24
25    if (character === '\\' && (next === '\n' || next === '\r')) {
26      index += next === '\r' && command.charAt(index + 2) === '\n' ? 2 : 1;
27      continue;
28    }
29    if (character === '\\' && [';', '|', '&'].includes(next)) {
30      current += `${character}${next}`;
31      index += 1;
32      continue;
33    }
34
35    if (quote !== null) {
36      current += character;
37      if (character === quote) quote = null;
38      continue;
39    }
40
41    if (character === '"' || character === "'") {
42      quote = character;
43      current += character;
44      continue;
45    }
46
47    if (character === '&' && next === '&') {
48      segments.push(current);
49      current = '';
50      index += 1;
51      continue;
52    }
53
54    // `2>&1`, `>&2` and `&>` are redirections, not a command sent to the background.
55    if (character === '&' && (command.charAt(index - 1) === '>' || command.charAt(index - 1) === '<' || next === '>')) {
56      current += character;
57      continue;
58    }
59
60    // `>|` is a single forced-clobber operator, so the pipe must not split it.
61    if (character === '|' && command.charAt(index - 1) === '>') {
62      current += character;
63      continue;
64    }
65
66    if (
67      character === ';' ||
68      character === '|' ||
69      character === '&' ||
70      character === '\n' ||
71      character === '\r'
72    ) {
73      segments.push(current);
74      current = '';
75      continue;
76    }
77
78    current += character;
79  }
80
81  segments.push(current);
82  return segments.map((segment) => segment.trim()).filter(Boolean);
83}
84
85export function tokenize(segment: string): string[] {
86  const tokens: string[] = [];
87  let current = '';
88  let quote: '"' | "'" | null = null;
89
90  for (let index = 0; index < segment.length; index += 1) {
91    const character = segment.charAt(index);
92    const next = segment.charAt(index + 1);
93
94    if (character === '\\' && (next === '\n' || next === '\r')) {
95      index += next === '\r' && segment.charAt(index + 2) === '\n' ? 2 : 1;
96      continue;
97    }
98
99    if (quote !== null) {
100      if (character === quote) {
101        quote = null;
102      } else {
103        current += character;
104      }
105      continue;
106    }
107
108    if (character === '"' || character === "'") {
109      quote = character;
110      continue;
111    }
112
113    if (/\s/.test(character)) {
114      if (current !== '') {
115        tokens.push(current);
116        current = '';
117      }
118      continue;
119    }
120
121    current += character;
122  }
123
124  if (current !== '') tokens.push(current);
125  return tokens;
126}
127
128export interface DangerHit {
129  reason:
130    | 'delete'
131    | 'delete-untracked'
132    | 'overwrite-history'
133    | 'discard-changes'
134    | 'discard-worktree'
135    | 'overwrite'
136    | 'permissions'
137    | 'drop-data'
138    | 'empty'
139    | 'empty-read';
140  target: string;
141  recursive?: boolean;
142}
143
144/**
145 * How much each kind of destruction outranks the others. Losing tracked work
146 * outranks clobbering one file, and anything outranks a redirect, which is the
147 * weakest signal and the one most likely to appear incidentally.
148 */
149const SEVERITY: Record<DangerHit['reason'], number> = {
150  delete: 6,
151  'delete-untracked': 6,
152  'discard-changes': 5,
153  'discard-worktree': 5,
154  'overwrite-history': 5,
155  'drop-data': 6,
156  permissions: 2,
157  'empty-read': 2,
158  empty: 2,
159  overwrite: 1,
160};
161
162const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
163const DELETE_EXECUTABLES = new Set(['rm', 'rmdir', 'rimraf', 'del']);
164const WRAPPERS = new Set(['sudo', 'env', 'command', 'nice', 'time', 'doas', 'timeout', 'nohup']);
165/** Control-flow words that sit where a command name would otherwise be. */
166const KEYWORDS = new Set([
167  'if', 'then', 'else', 'elif', 'fi',
168  'while', 'until', 'do', 'done',
169  'case', 'esac', 'in',
170  '{', '}', '(', ')', '!', 'exec',
171]);
172const PERMISSION_EXECUTABLES = new Set(['chmod', 'chown', 'chgrp']);
173const DATABASE_CLIENTS = new Set([
174  'psql',
175  'mysql',
176  'mariadb',
177  'sqlite3',
178  'duckdb',
179  'cockroach',
180  'clickhouse-client',
181  'mongosh',
182  'mongo',
183]);
184const DROP_SQL =
185  /\b(?:drop\s+(table|database|schema|collection|view)|truncate(?:\s+table)?)\s+(?:if\s+exists\s+)?([A-Za-z0-9_."`$-]+)/i;
186const DROP_MONGO = /\b(?:db\.dropDatabase\(\)|\.drop\(\))/;
187
188/** Writing here destroys nothing, so it must never register as an overwrite. */
189const NULL_SINKS = /^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/;
190
191/**
192 * Where scratch work goes: a temp folder, a path made by `mktemp`, or a file
193 * named by a shell variable. Emptying one of these is routine, not a loss
194 * worth stopping for. `$HOME`, `$PWD` and project paths are real files.
195 */
196const TEMP_ROOT = /^(?:~?\/?(?:private\/)?|\.\/|\.)tmp\/|^\/(?:private\/)?var\/folders\/|^\/dev\/shm\//;
197const VARIABLE_ROOT = /^\$\{?(?!(?:HOME|PWD|OLDPWD)\b)[A-Za-z_][A-Za-z0-9_]*\}?(?:\/|$)/;
198const MKTEMP = /^(?:\$\(|`)\s*mktemp\b/;
199
200export function isScratchTarget(target: string): boolean {
201  return TEMP_ROOT.test(target) || VARIABLE_ROOT.test(target) || MKTEMP.test(target);
202}
203
204/** `truncate -s 0 f`: the file is cut to nothing. Any other size keeps or pads it. */
205export function truncatesToNothing(argv: readonly string[]): boolean {
206  return argv.some(
207    (argument, index) =>
208      argument === '-s0' ||
209      argument === '--size=0' ||
210      ((argument === '-s' || argument === '--size') && argv[index + 1] === '0'),
211  );
212}
213
214export function isNullSink(target: string): boolean {
215  return NULL_SINKS.test(target);
216}
217
218export interface Redirect {
219  target: string;
220  append: boolean;
221  /** `1` for output, `2` for errors, `&` for both. */
222  fd: string;
223}
224
225export interface Scan {
226  /** Files the command's output or errors are sent to; never `2>&1` or a null sink. */
227  redirects: Redirect[];
228  /** Files fed to the command with `<`. */
229  inputs: string[];
230  /** The command with every redirection, its target and heredoc markers blanked out. */
231  stripped: string;
232}
233
234/** The index of the `)` that closes the `(` at `open`, or the last index. */
235function closingParen(text: string, open: number): number {
236  let depth = 0;
237  let quote: string | null = null;
238  for (let index = open; index < text.length; index += 1) {
239    const character = text.charAt(index);
240    if (quote !== null) {
241      if (character === quote) quote = null;
242      continue;
243    }
244    if (character === '"' || character === "'") quote = character;
245    else if (character === '\\') index += 1;
246    else if (character === '(') depth += 1;
247    else if (character === ')') {
248      depth -= 1;
249      if (depth === 0) return index;
250    }
251  }
252  return text.length - 1;
253}
254
255function readWord(text: string, from: number): { value: string; end: number } {
256  let index = from;
257  while (index < text.length && (text.charAt(index) === ' ' || text.charAt(index) === '\t')) index += 1;
258  let value = '';
259  let quote: string | null = null;
260  for (; index < text.length; index += 1) {
261    const character = text.charAt(index);
262    if (quote !== null) {
263      if (character === quote) quote = null;
264      else value += character;
265      continue;
266    }
267    if (character === '$' && text.charAt(index + 1) === '(') {
268      const close = closingParen(text, index + 1);
269      value += text.slice(index, close + 1);
270      index = close;
271      continue;
272    }
273    if (character === '`') {
274      const close = text.indexOf('`', index + 1);
275      const end = close < 0 ? text.length - 1 : close;
276      value += text.slice(index, end + 1);
277      index = end;
278      continue;
279    }
280    if (character === '"' || character === "'") {
281      quote = character;
282      continue;
283    }
284    if (character === '\\' && index + 1 < text.length) {
285      value += text.charAt(index + 1);
286      index += 1;
287      continue;
288    }
289    if (/[\s;|&<>()]/.test(character)) break;
290    value += character;
291  }
292  return { value, end: index };
293}
294
295/** The file descriptor digits just before `index`, when they stand alone. */
296function fdBefore(text: string, index: number): { fd: string; start: number } | null {
297  let cursor = index - 1;
298  while (cursor >= 0 && /\d/.test(text.charAt(cursor))) cursor -= 1;
299  if (cursor === index - 1) return null;
300  if (cursor >= 0 && !/\s/.test(text.charAt(cursor))) return null;
301  return { fd: text.slice(cursor + 1, index), start: cursor + 1 };
302}
303
304/**
305 * Reads a segment's redirections from its raw text, so a quoted `">"`, an
306 * arithmetic `(( n > 5 ))` or a test `[[ a > b ]]` is never taken for one.
307 * What runs inside `$( )`, backticks or `<( )` is read as its own command.
308 */
309export function scanCommand(text: string): Scan {
310  const redirects: Redirect[] = [];
311  const inputs: string[] = [];
312  const out = text.split('');
313  const blank = (from: number, to: number): void => {
314    for (let cursor = from; cursor < to; cursor += 1) out[cursor] = ' ';
315  };
316  let quote: string | null = null;
317  let test = false;
318  for (let index = 0; index < text.length; index += 1) {
319    const character = text.charAt(index);
320    const next = text.charAt(index + 1);
321    if (quote !== null) {
322      if (character === quote) quote = null;
323      else if (character === '\\' && quote === '"') index += 1;
324      continue;
325    }
326    if (character === '\\') {
327      index += 1;
328      continue;
329    }
330    if (character === '"' || character === "'") {
331      quote = character;
332      continue;
333    }
334    if (next === '(' && ['$', '<', '>', '('].includes(character)) {
335      index = closingParen(text, index + 1);
336      continue;
337    }
338    if (character === '`') {
339      const close = text.indexOf('`', index + 1);
340      index = close < 0 ? text.length : close;
341      continue;
342    }
343    if (character === '[' && next === '[') {
344      test = true;
345      index += 1;
346      continue;
347    }
348    if (character === ']' && next === ']') {
349      test = false;
350      index += 1;
351      continue;
352    }
353    if (test) continue;
354    if (character === '>') {
355      let start = index;
356      let fd = '1';
357      if (text.charAt(index - 1) === '&') {
358        start = index - 1;
359        fd = '&';
360      } else {
361        const before = fdBefore(text, index);
362        if (before !== null) {
363          start = before.start;
364          fd = before.fd;
365        }
366      }
367      let operatorEnd = index + 1;
368      let append = false;
369      if (text.charAt(operatorEnd) === '>') {
370        append = true;
371        operatorEnd += 1;
372      } else if (text.charAt(operatorEnd) === '|') {
373        operatorEnd += 1;
374      }
375      if (text.charAt(operatorEnd) === '&') {
376        // `2>&1`, `>&2`: one stream joins another; no file is written.
377        const word = readWord(text, operatorEnd + 1);
378        blank(start, word.end);
379        index = word.end - 1;
380        continue;
381      }
382      const word = readWord(text, operatorEnd);
383      blank(start, word.end);
384      if (word.value !== '' && !NULL_SINKS.test(word.value)) redirects.push({ target: word.value, append, fd });
385      index = word.end - 1;
386      continue;
387    }
388    if (character === '<') {
389      if (next === '<') {
390        // `<<<` here-string or `<<EOF` heredoc marker: data, not a file.
391        let from = index + 2;
392        if (text.charAt(from) === '<' || text.charAt(from) === '-') from += 1;
393        const word = readWord(text, from);
394        blank(index, word.end);
395        index = word.end - 1;
396        continue;
397      }
398      const before = fdBefore(text, index);
399      const word = readWord(text, index + 1);
400      blank(before?.start ?? index, word.end);
401      if (word.value !== '') inputs.push(word.value);
402      index = word.end - 1;
403    }
404  }
405  return { redirects, inputs, stripped: out.join('') };
406}
407
408/** Split into pipelines: at `&&`, `||`, `;`, newlines and a lone `&`, never at a single `|`. */
409export function splitPipelines(command: string): string[] {
410  const pipelines: string[] = [];
411  let current = '';
412  let quote: '"' | "'" | null = null;
413  for (let index = 0; index < command.length; index += 1) {
414    const character = command.charAt(index);
415    const next = command.charAt(index + 1);
416    const previous = command.charAt(index - 1);
417    if (character === '\\' && (next === '\n' || next === '\r')) {
418      index += next === '\r' && command.charAt(index + 2) === '\n' ? 2 : 1;
419      continue;
420    }
421    if (character === '\\' && [';', '|', '&'].includes(next)) {
422      current += `${character}${next}`;
423      index += 1;
424      continue;
425    }
426    if (quote !== null) {
427      current += character;
428      if (character === quote) quote = null;
429      continue;
430    }
431    if (character === '"' || character === "'") {
432      quote = character;
433      current += character;
434      continue;
435    }
436    if ((character === '&' && next === '&') || (character === '|' && next === '|')) {
437      pipelines.push(current);
438      current = '';
439      index += 1;
440      continue;
441    }
442    const redirectAmpersand = character === '&' && (previous === '>' || previous === '<' || next === '>');
443    if (character === ';' || character === '\n' || character === '\r' || (character === '&' && !redirectAmpersand)) {
444      pipelines.push(current);
445      current = '';
446      continue;
447    }
448    current += character;
449  }
450  pipelines.push(current);
451  return pipelines.map((pipeline) => pipeline.trim()).filter(Boolean);
452}
453
454function executableName(value: string): string {
455  return value.split('/').pop() ?? value;
456}
457
458/** Matches a redirection operator, standalone (`>`, `2>`) or attached (`2>/dev/null`). */
459const REDIRECTION = /^\d*>{1,2}\|?/;
460
461/**
462 * Everything before the first redirection.
463 *
464 * Without this, `rm -rf node_modules > /dev/null` reports its target as
465 * "node_modules, >, /dev/null": the operator and the sink read as extra files
466 * being deleted, which is both wrong and alarming.
467 */
468function stripRedirections(argv: string[]): string[] {
469  const cut = argv.findIndex((argument) => REDIRECTION.test(argument));
470  return cut === -1 ? argv : argv.slice(0, cut);
471}
472
473function positionalArgs(argv: string[], start: number): string[] {
474  return stripRedirections(argv.slice(start)).filter(
475    (argument) =>
476      !argument.startsWith('-') &&
477      argument !== '{}' &&
478      argument !== '+' &&
479      argument !== ';' &&
480      argument !== '\\;',
481  );
482}
483
484/** Drop everything from an unquoted `#` onward, so a comment is never a target. */
485export function stripComment(segment: string): string {
486  let quote: '"' | "'" | null = null;
487  for (let index = 0; index < segment.length; index += 1) {
488    const character = segment.charAt(index);
489    if (quote !== null) {
490      if (character === quote) quote = null;
491      continue;
492    }
493    if (character === '"' || character === "'") {
494      quote = character;
495      continue;
496    }
497    if (character === '#' && (index === 0 || /\s/.test(segment.charAt(index - 1)))) {
498      return segment.slice(0, index);
499    }
500  }
501  return segment;
502}
503
504/**
505 * Lift heredoc bodies out of the command.
506 *
507 * Newlines separate commands, so without this a file being *written* with
508 * documentation about `rm -rf build` is read as a command that performs it. The
509 * bodies are returned rather than discarded because `bash <<EOF` really does
510 * execute what is inside.
511 */
512export function stripHeredocs(command: string): { command: string; bodies: string[] } {
513  if (!command.includes('<<')) return { command, bodies: [] };
514  const lines = command.split('\n');
515  const kept: string[] = [];
516  const bodies: string[] = [];
517  let index = 0;
518  while (index < lines.length) {
519    const line = lines[index] ?? '';
520    kept.push(line);
521    index += 1;
522    const header = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
523    if (header) {
524      const delimiter = header[2] ?? '';
525      const body: string[] = [];
526      while (index < lines.length && (lines[index] ?? '').trim() !== delimiter) {
527        body.push(lines[index] ?? '');
528        index += 1;
529      }
530      index += 1;
531      bodies.push(body.join('\n'));
532    }
533  }
534  return { command: kept.join('\n'), bodies };
535}
536
537function stripWrapper(argv: string[]): string[] {
538  const normalized = [...argv];
539
540  const dropNoise = (): void => {
541    while (
542      normalized[0] !== undefined &&
543      (ASSIGNMENT.test(normalized[0]) || KEYWORDS.has(normalized[0]))
544    ) {
545      normalized.shift();
546    }
547  };
548
549  dropNoise();
550  while (WRAPPERS.has(executableName(normalized[0] ?? ''))) {
551    const wrapper = executableName(normalized.shift() ?? '');
552    while (normalized[0]?.startsWith('-')) {
553      const option = normalized.shift() ?? '';
554      const takesValue =
555        (wrapper === 'sudo' && ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D'].includes(option)) ||
556        (wrapper === 'env' && ['-u', '--unset'].includes(option)) ||
557        (wrapper === 'nice' && option === '-n') ||
558        (wrapper === 'time' && ['-f', '-o'].includes(option)) ||
559        (wrapper === 'timeout' && ['-s', '--signal', '-k', '--kill-after'].includes(option));
560      if (takesValue) normalized.shift();
561    }
562    // `timeout 10 rm -rf dist`: the duration comes before the command it runs.
563    if (wrapper === 'timeout') normalized.shift();
564    dropNoise();
565  }
566
567  if (normalized[0] === 'npx') return normalized.slice(1);
568  if (
569    ['yarn', 'pnpm', 'bun'].includes(normalized[0] ?? '') &&
570    ['dlx', 'exec', 'x'].includes(normalized[1] ?? '')
571  ) {
572    return normalized.slice(2);
573  }
574  return normalized;
575}
576
577function deleteHit(argv: string[], executableIndex: number, fallbackTarget = 'files'): DangerHit {
578  const executable = executableName(argv[executableIndex] ?? '');
579  const targets = positionalArgs(argv, executableIndex + 1);
580  const recursive =
581    executable === 'rimraf' ||
582    stripRedirections(argv.slice(executableIndex + 1))
583      .some((argument) => argument === '--recursive' || /^-[^-]*[rR]/.test(argument)) ||
584    targets.some((value) => value.endsWith('/'));
585  return {
586    reason: 'delete',
587    target: targets.join(', ') || fallbackTarget,
588    recursive,
589  };
590}
591
592function gitCommand(argv: string[]): { index: number; subcommand: string } | null {
593  if (executableName(argv[0] ?? '') !== 'git') return null;
594  let index = 1;
595  while (index < argv.length) {
596    const argument = argv[index] ?? '';
597    if (argument === '-C' || argument === '-c') {
598      index += 2;
599      continue;
600    }
601    if (['--git-dir', '--work-tree', '--namespace'].includes(argument)) {
602      index += 2;
603      continue;
604    }
605    if (argument.startsWith('-')) {
606      index += 1;
607      continue;
608    }
609    return { index, subcommand: argument };
610  }
611  return null;
612}
613
614/**
615 * Command substitutions and subshells, extracted with quote tracking so that
616 * prose merely *mentioning* a command is not treated as one. Without this,
617 * `git commit -m "fix (rm -rf dist) call"` warns about a deletion.
618 */
619function nestedCommands(command: string): string[] {
620  const nested: string[] = [];
621  let quote: '"' | "'" | null = null;
622  for (let index = 0; index < command.length; index += 1) {
623    const character = command.charAt(index);
624    if (quote !== null) {
625      if (character === quote) quote = null;
626      // Inside double quotes a substitution still runs; inside single quotes
627      // nothing does, so only the former is worth extracting.
628      else if (quote === '"' && character === '$' && command.charAt(index + 1) === '(') {
629        const end = command.indexOf(')', index);
630        if (end > index) {
631          nested.push(command.slice(index + 2, end));
632          index = end;
633        }
634      } else if (quote === '"' && character === '`') {
635        const end = command.indexOf('`', index + 1);
636        if (end > index) {
637          nested.push(command.slice(index + 1, end));
638          index = end;
639        }
640      }
641      continue;
642    }
643    if (character === '"' || character === "'") {
644      quote = character;
645      continue;
646    }
647    if (character === '$' && command.charAt(index + 1) === '(') {
648      const end = command.indexOf(')', index);
649      if (end > index) {
650        nested.push(command.slice(index + 2, end));
651        index = end;
652      }
653      continue;
654    }
655    if (character === '`') {
656      const end = command.indexOf('`', index + 1);
657      if (end > index) {
658        nested.push(command.slice(index + 1, end));
659        index = end;
660      }
661      continue;
662    }
663    if (character === '(' && (index === 0 || /[\s;|&]/.test(command.charAt(index - 1)))) {
664      const end = command.indexOf(')', index);
665      if (end > index) {
666        nested.push(command.slice(index + 1, end));
667        index = end;
668      }
669    }
670  }
671  return nested.filter((payload) => payload.trim() !== '');
672}
673
674/** True for `-c` and for any bundled cluster containing it, such as `-lc` or `-cx`. */
675function shellCommandFlag(argument: string): boolean {
676  return (
677    argument === '--command' ||
678    (/^-[a-zA-Z]+$/.test(argument) && argument.includes('c'))
679  );
680}
681
682/**
683 * A path-shaped argument to `git checkout` means "throw away my edits to this",
684 * which is nothing like switching branches. Refs and paths are genuinely
685 * ambiguous, so this leans toward warning: a needless caution costs a glance,
686 * while silence here costs someone their work.
687 */
688function looksLikePath(value: string): boolean {
689  return value === '.' || value.includes('*') || /\.[a-zA-Z]{1,5}$/.test(value) || value.endsWith('/');
690}
691
692function collectSegmentHits(segment: string, depth: number): DangerHit[] {
693  const hits: DangerHit[] = [];
694  if (depth > 8) return hits;
695
696  const cleaned = stripComment(segment);
697
698  for (const nested of nestedCommands(cleaned)) {
699    hits.push(...collectHits(nested, depth + 1));
700  }
701
702  const argv = stripWrapper(tokenize(cleaned));
703  const executable = executableName(argv[0] ?? '');
704
705  // A shell invoked with -c runs its argument, so the argument is the command.
706  if (['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(executable)) {
707    const flagIndex = argv.findIndex((argument, index) => index > 0 && shellCommandFlag(argument));
708    const payload = flagIndex >= 0 ? argv[flagIndex + 1] : undefined;
709    if (payload !== undefined) hits.push(...collectHits(payload, depth + 1));
710  }
711
712  if (DELETE_EXECUTABLES.has(executable)) hits.push(deleteHit(argv, 0));
713
714  const xargsIndex = argv.findIndex((argument) => executableName(argument) === 'xargs');
715  if (xargsIndex >= 0) {
716    const deleteIndex = argv.findIndex(
717      (argument, index) => index > xargsIndex && DELETE_EXECUTABLES.has(executableName(argument)),
718    );
719    if (deleteIndex >= 0) hits.push(deleteHit(argv, deleteIndex, 'the files it just listed'));
720  }
721
722  if (executable === 'find') {
723    const root = argv[1] ?? '.';
724    if (argv.includes('-delete')) {
725      hits.push({ reason: 'delete', target: `matching files inside '${root}'`, recursive: false });
726    }
727    const execIndex = argv.findIndex((argument) => argument === '-exec' || argument === '-execdir');
728    if (execIndex >= 0) {
729      const deleteIndex = argv.findIndex(
730        (argument, index) => index > execIndex && DELETE_EXECUTABLES.has(executableName(argument)),
731      );
732      if (deleteIndex >= 0) {
733        hits.push({
734          reason: 'delete',
735          target: `matching files inside '${root}'`,
736          recursive: false,
737        });
738      }
739    }
740  }
741
742  if (executable === 'truncate' && truncatesToNothing(argv)) {
743    const target = argv.at(-1) ?? 'a file';
744    if (!isScratchTarget(target)) hits.push({ reason: 'empty', target });
745  }
746
747  if (executable === 'dd') {
748    const output = argv.find((argument) => argument.startsWith('of='));
749    if (output !== undefined) {
750      hits.push({ reason: 'overwrite', target: output.slice(3) || 'a file or device' });
751    }
752  }
753
754  if (PERMISSION_EXECUTABLES.has(executable)) {
755    const options = argv.slice(1).filter((argument) => argument.startsWith('-'));
756    const positionals = positionalArgs(argv, 1);
757    // chmod and chown take the new mode or owner first; the rest are targets.
758    const targets = positionals.length > 1 ? positionals.slice(1) : positionals;
759    hits.push({
760      reason: 'permissions',
761      target: targets.join(', ') || 'files',
762      recursive: options.some((option) => option === '--recursive' || /^-[^-]*R/.test(option)),
763    });
764  }
765
766  if (executable === 'dropdb') {
767    hits.push({ reason: 'drop-data', target: positionalArgs(argv, 1).at(-1) ?? 'a database' });
768  }
769
770  if (executable === 'redis-cli' && argv.some((argument) => /^flush(all|db)$/i.test(argument))) {
771    hits.push({ reason: 'drop-data', target: 'everything stored in Redis' });
772  }
773
774  if (
775    executable === 'prisma' &&
776    ((argv[1] === 'migrate' && argv[2] === 'reset') ||
777      (argv[1] === 'db' && argv[2] === 'push' && argv.includes('--force-reset')))
778  ) {
779    hits.push({ reason: 'drop-data', target: "the project's database" });
780  }
781
782  if (DATABASE_CLIENTS.has(executable)) {
783    for (const argument of argv.slice(1)) {
784      const sql = DROP_SQL.exec(argument);
785      if (sql !== null) {
786        const kind = (sql[1] ?? 'table').toLowerCase();
787        hits.push({ reason: 'drop-data', target: `${kind} ${sql[2] ?? ''}`.trim() });
788        break;
789      }
790      if (DROP_MONGO.test(argument)) {
791        hits.push({ reason: 'drop-data', target: 'a database collection' });
792        break;
793      }
794    }
795  }
796
797  // Sending output to a file is a write like any other. It is a loss only
798  // when the command writes nothing: `: > f`, `> f`, `cat /dev/null > f`.
799  const scan = scanCommand(cleaned);
800  const words = stripWrapper(tokenize(scan.stripped));
801  const writesNothing =
802    words.length === 0 ||
803    (words.length === 1 && (words[0] === ':' || words[0] === 'true')) ||
804    (words.length === 2 && words[0] === 'cat' && words[1] === '/dev/null');
805  if (writesNothing) {
806    for (const redirect of scan.redirects) {
807      if (!redirect.append && !isScratchTarget(redirect.target)) {
808        hits.push({ reason: 'empty', target: redirect.target });
809      }
810    }
811  }
812
813  const git = gitCommand(argv);
814  if (git !== null) {
815    const gitArgs = argv.slice(git.index + 1);
816    const positionals = positionalArgs(argv, git.index + 1);
817
818    if (
819      git.subcommand === 'push' &&
820      (gitArgs.some((argument) =>
821        ['--force', '--force-with-lease'].includes(argument) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(argument)) ||
822        gitArgs.some((argument) => argument.startsWith('+')))
823    ) {
824      hits.push({ reason: 'overwrite-history', target: positionals.join(' ') || 'remote' });
825    }
826
827    if (git.subcommand === 'reset' && gitArgs.includes('--hard')) {
828      hits.push({
829        reason: 'discard-changes',
830        target: positionals[0] ?? 'working copy',
831      });
832    }
833
834    if (
835      git.subcommand === 'clean' &&
836      gitArgs.some((argument) => argument === '--force' || /^-[a-z]*f[a-z]*$/i.test(argument))
837    ) {
838      hits.push({ reason: 'delete-untracked', target: 'untracked files' });
839    }
840
841    if (
842      git.subcommand === 'checkout' &&
843      !gitArgs.some((argument) => ['-b', '-B', '--track', '--orphan'].includes(argument))
844    ) {
845      const separator = gitArgs.indexOf('--');
846      const paths = separator >= 0 ? gitArgs.slice(separator + 1) : positionals.filter(looksLikePath);
847      if (paths.length > 0) {
848        hits.push({ reason: 'discard-worktree', target: paths.join(', ') });
849      }
850    }
851
852    // `git restore --staged` only unstages; the working copy is untouched, so
853    // warning about it would be a false alarm about losing work.
854    if (git.subcommand === 'restore') {
855      const stagedOnly =
856        gitArgs.some((argument) => argument === '--staged' || argument === '-S') &&
857        !gitArgs.some((argument) => argument === '--worktree' || argument === '-W');
858      if (!stagedOnly) {
859        hits.push({ reason: 'discard-worktree', target: positionals.join(', ') || 'working copy' });
860      }
861    }
862
863    if (
864      git.subcommand === 'branch' &&
865      gitArgs.some((argument) => ['-D', '-d', '--delete'].includes(argument))
866    ) {
867      hits.push({ reason: 'delete', target: positionals.join(', ') || 'a branch', recursive: false });
868    }
869
870    if (git.subcommand === 'stash' && ['drop', 'clear'].includes(positionals[0] ?? '')) {
871      hits.push({ reason: 'delete', target: 'stashed changes', recursive: false });
872    }
873
874    if (
875      git.subcommand === 'worktree' &&
876      positionals[0] === 'remove' &&
877      gitArgs.some((argument) => argument === '--force' || argument === '-f')
878    ) {
879      hits.push({ reason: 'delete', target: positionals[1] ?? 'a worktree', recursive: true });
880    }
881  }
882
883  return hits;
884}
885
886/**
887 * `sort f > f`, `sed … f > f`, `cat f | … > f`: the shell empties f before
888 * anything in the pipeline reads it, so what was in it is lost.
889 */
890function selfOverwriteHits(command: string): DangerHit[] {
891  const hits: DangerHit[] = [];
892  for (const pipeline of splitPipelines(command)) {
893    const stages = splitSegments(stripComment(pipeline)).map((stage) => scanCommand(stage));
894    const reads = new Set<string>();
895    for (const stage of stages) {
896      stage.inputs.forEach((input) => reads.add(input));
897      const words = stripWrapper(tokenize(stage.stripped));
898      // echo and printf write their words; they read no file.
899      if (['echo', 'printf', 'tee'].includes(executableName(words[0] ?? ''))) continue;
900      words.slice(1).filter((word) => !word.startsWith('-')).forEach((word) => reads.add(word));
901    }
902    for (const stage of stages) {
903      for (const redirect of stage.redirects) {
904        if (!redirect.append && reads.has(redirect.target)) hits.push({ reason: 'empty-read', target: redirect.target });
905      }
906    }
907  }
908  return hits;
909}
910
911function collectHits(command: string, depth: number): DangerHit[] {
912  const { command: withoutBodies, bodies } = stripHeredocs(command);
913  const hits: DangerHit[] = [];
914  for (const segment of splitSegments(withoutBodies)) {
915    hits.push(...collectSegmentHits(segment, depth));
916  }
917  hits.push(...selfOverwriteHits(withoutBodies));
918  // A heredoc fed to a shell is executed; fed to anything else it is data.
919  const firstWord = executableName(tokenize(splitSegments(withoutBodies)[0] ?? '')[0] ?? '');
920  if (bodies.length > 0 && ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(firstWord)) {
921    for (const body of bodies) hits.push(...collectHits(body, depth + 1));
922  }
923  return hits;
924}
925
926/**
927 * The most severe hit in the whole command, so an incidental redirect can never
928 * hide a deletion that happens beside it.
929 */
930export function detectDanger(command: string): DangerHit | null {
931  const hits = collectHits(command, 0);
932  if (hits.length === 0) return null;
933  return hits.reduce((best, hit) => (SEVERITY[hit.reason] > SEVERITY[best.reason] ? hit : best));
934}
935
engine/src/rules/bash-rules.ts 881 lines
1import {
2  detectDanger,
3  isNullSink,
4  scanCommand,
5  splitPipelines,
6  splitSegments,
7  stripComment,
8  stripHeredocs,
9  tokenize,
10  truncatesToNothing,
11} from '../bash.ts';
12import { counted, plural } from '../words.ts';
13import type { Level, Line } from '../types.ts';
14
15export interface DangerHint {
16  count: number;
17  cap: boolean;
18}
19
20interface SegLine {
21  glyph: Line['glyph'];
22  id: string;
23  stem: string;
24  facts?: string;
25  tier: Line['tier'];
26  generic?: true;
27}
28
29type SegmentKind =
30  | 'test'
31  | 'install'
32  | 'remove'
33  | 'build'
34  | 'read'
35  | 'action'
36  | 'generic';
37
38const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
39const READ_COMMANDS = new Set([
40  'ls',
41  'cat',
42  'head',
43  'tail',
44  'pwd',
45  'which',
46  'echo',
47  'grep',
48  'rg',
49  'wc',
50  'stat',
51]);
52const PYTHONS = new Set(['python', 'python3']);
53const NAVIGATION = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'done', 'fi', 'esac']);
54const TEST_BINS = new Set([
55  'vitest',
56  'jest',
57  'pytest',
58  'mocha',
59  'playwright',
60]);
61
62function lineText(stem: string, facts?: string): string {
63  return facts === undefined ? stem : `${stem} ${facts}`;
64}
65
66function asLine(
67  command: string,
68  segment: SegLine,
69  tier = segment.tier,
70): Line {
71  return {
72    glyph: segment.glyph,
73    id: segment.id,
74    stem: segment.stem,
75    ...(segment.facts === undefined ? {} : { facts: segment.facts }),
76    text: lineText(segment.stem, segment.facts),
77    severity: 'info',
78    tier,
79    raw: command,
80    ...(segment.generic === true ? { generic: true as const } : {}),
81  };
82}
83
84function packageNames(argv: string[]): string | null {
85  const names = argv.slice(2).filter((argument) => !argument.startsWith('-'));
86  return names.length === 0 ? null : names.join(', ');
87}
88
89function testBin(argv: string[]): string {
90  const command = argv[0] ?? '';
91  return command === 'npx' ? (argv[1] ?? 'test runner') : command;
92}
93
94function kindOfSegment(argv: string[]): SegmentKind {
95  const command = argv[0] ?? '';
96  const subcommand = argv[1] ?? '';
97  const bin = testBin(argv);
98
99  if (
100    TEST_BINS.has(bin) ||
101    (command === 'cargo' && subcommand === 'test') ||
102    (command === 'go' && subcommand === 'test') ||
103    (command === 'deno' && subcommand === 'test') ||
104    (command === 'node' && argv.includes('--test')) ||
105    (PYTHONS.has(command) &&
106      subcommand === '-m' &&
107      ['unittest', 'pytest'].includes(argv[2] ?? '')) ||
108    (PACKAGE_MANAGERS.has(command) &&
109      (subcommand === 'test' ||
110        (subcommand === 'run' && argv[2] === 'test')))
111  ) {
112    return 'test';
113  }
114
115  if (
116    (['pip', 'pip3', 'poetry', 'brew', 'gem', 'cargo'].includes(command) && ['install', 'add'].includes(subcommand)) ||
117    (command === 'uv' && ['sync', 'add', 'pip'].includes(subcommand)) ||
118    (['yarn', 'pnpm', 'bun'].includes(command) && subcommand === 'add')
119  ) {
120    return 'install';
121  }
122
123  if (command === 'make' && (subcommand === 'test' || subcommand === 'check')) return 'test';
124  if (PACKAGE_MANAGERS.has(command) && subcommand === 'run' && /^(test|e2e)([:-]|$)/.test(argv[2] ?? '')) return 'test';
125  if (command === 'swift' && subcommand === 'test') return 'test';
126  if ((command === 'swift' && subcommand === 'build') || command === 'xcodebuild') return 'build';
127  if (
128    (command === 'make' && (subcommand === '' || ['build', 'all'].includes(subcommand))) ||
129    (['yarn', 'pnpm', 'bun'].includes(command) && subcommand === 'build') ||
130    (command === 'docker' && subcommand === 'build')
131  ) {
132    return 'build';
133  }
134
135  if (
136    PACKAGE_MANAGERS.has(command) &&
137    (subcommand === 'ci' ||
138      subcommand === 'install' ||
139      subcommand === 'add' ||
140      subcommand === 'i')
141  ) {
142    return 'install';
143  }
144
145  if (
146    PACKAGE_MANAGERS.has(command) &&
147    (subcommand === 'remove' ||
148      subcommand === 'uninstall' ||
149      subcommand === 'rm')
150  ) {
151    return 'remove';
152  }
153
154  if (
155    command === 'tsc' ||
156    (command === 'cargo' && subcommand === 'build') ||
157    (command === 'go' && subcommand === 'build') ||
158    (PACKAGE_MANAGERS.has(command) &&
159      subcommand === 'run' &&
160      argv[2] === 'build')
161  ) {
162    return 'build';
163  }
164
165  if (
166    READ_COMMANDS.has(command) ||
167    (command === 'find' &&
168      !argv.includes('-delete') &&
169      !argv.includes('-exec') &&
170      !argv.includes('-execdir')) ||
171    (command === 'git' &&
172      ['status', 'log', 'diff', 'show'].includes(subcommand))
173  ) {
174    return 'read';
175  }
176
177  if (
178    command === 'git' ||
179    ['mkdir', 'cp', 'mv', 'touch', 'chmod', 'curl', 'wget'].includes(command)
180  ) {
181    return 'action';
182  }
183
184  return 'generic';
185}
186
187function testLine(level: Level, runner: string): SegLine {
188  return {
189    glyph: '→',
190    id: 'bash.test',
191    tier: 'action',
192    stem:
193      level === 'plain'
194        ? 'Checking everything still works…'
195        : `Running the test suite (${runner || 'test runner'})`,
196  };
197}
198
199function gitLine(argv: string[], level: Level): SegLine {
200  const subcommand = argv[1] ?? '';
201  const positional = argv.slice(2).filter((a) => !a.startsWith('-'));
202  if (subcommand === '--version' || subcommand === 'version') return line('Checking which version of git is installed');
203  if (
204    ['blame', 'grep', 'ls-files', 'rev-parse', 'remote', 'shortlog', 'describe', 'reflog'].includes(subcommand) ||
205    (subcommand === 'tag' && positional.length === 0) ||
206    (subcommand === 'stash' && positional[0] === 'list') ||
207    (subcommand === 'branch' && positional.length === 0)
208  ) {
209    return line('Checking the state of the work');
210  }
211  if (subcommand === 'fetch') return line('Bringing in the latest shared work', 'action');
212  if (subcommand === 'stash' && ['pop', 'apply'].includes(positional[0] ?? '')) return line('Bringing back edits that were set aside', 'action');
213  if (subcommand === 'stash' && positional.length === 0) return line('Setting unsaved edits aside for later', 'action');
214  if (subcommand === 'rebase') return line('Replaying the work on top of another track', 'action');
215  if (subcommand === 'cherry-pick') return line('Copying a saved change from another track', 'action');
216
217  if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
218    return {
219      glyph: '→',
220      id: 'bash.read',
221      tier: 'read',
222      stem:
223        level === 'plain'
224          ? 'Checking the state of the work'
225          : `Inspecting changes with git ${subcommand}`,
226    };
227  }
228
229  if (subcommand === 'commit') {
230    return {
231      glyph: '→',
232      id: 'bash.commit',
233      tier: 'action',
234      stem:
235        level === 'plain'
236          ? 'Saving a checkpoint of the work'
237          : 'Committing changes',
238    };
239  }
240
241  if (
242    (subcommand === 'checkout' && argv.includes('-b')) ||
243    subcommand === 'branch'
244  ) {
245    const branch =
246      subcommand === 'checkout'
247        ? (argv[argv.indexOf('-b') + 1] ?? 'a branch')
248        : (argv[2] ?? 'a branch');
249    return {
250      glyph: '→',
251      id: 'bash.branch',
252      tier: 'action',
253      stem:
254        level === 'plain'
255          ? 'Starting work on a separate track'
256          : `Creating branch ${branch}`,
257    };
258  }
259
260  if (subcommand === 'checkout' || subcommand === 'switch') {
261    const target =
262      argv.slice(2).find((argument) => !argument.startsWith('-')) ??
263      'another branch';
264    return {
265      glyph: '→',
266      id: 'bash.branch',
267      tier: 'action',
268      stem:
269        level === 'plain'
270          ? 'Moving to another track of work'
271          : `Switching to ${target}`,
272    };
273  }
274
275  const developer: Record<string, string> = {
276    add: 'Staging changes',
277    pull: 'Pulling changes',
278    push: 'Pushing changes',
279    merge: 'Merging changes',
280  };
281  const plain: Record<string, string> = {
282    add: 'Preparing changes for a checkpoint',
283    pull: 'Bringing in the latest shared work',
284    push: 'Sharing the saved work',
285    merge: 'Combining separate tracks of work',
286  };
287  const selected = level === 'plain' ? plain[subcommand] : developer[subcommand];
288  return {
289    glyph: '→',
290    // A git step with its own sentence is not the catch-all.
291    id: selected === undefined ? 'bash.generic' : `bash.git.${subcommand}`,
292    tier: 'action',
293    stem: selected ?? (level === 'plain' ? 'Managing saved work' : 'Running git'),
294  };
295}
296
297function genericLine(segment: string, level: Level): SegLine {
298  if (level === 'plain') {
299    return {
300      glyph: '→',
301      id: 'bash.generic',
302      tier: 'action',
303      stem: "Running one of the project's own tools",
304      generic: true,
305    };
306  }
307
308  const argv = tokenize(segment);
309  const executable = argv[0] ?? 'project-script';
310  const fileName = executable.split('/').pop() ?? executable;
311  const name = fileName.replace(/\.[^.]+$/, '') || 'project-script';
312  const optionCount = Math.max(0, argv.length - 1);
313  return {
314    glyph: '→',
315    id: 'bash.generic',
316    tier: 'action',
317    stem: `Running a project script (${name})`,
318    ...(optionCount === 0
319      ? {}
320      : {
321          facts: `(+${optionCount} option${optionCount === 1 ? '' : 's'})`,
322        }),
323    generic: true,
324  };
325}
326
327const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
328// Shell words that only lead into the real command (`then echo hi`, `if [ -f x ]`).
329const FLOW = new Set(['do', 'then', 'else', 'elif', 'if']);
330
331/** The words that say what a segment runs: no env prefix, wrapper or path. */
332export function normalizeArgv(raw: string[]): string[] {
333  let argv = [...raw];
334  while (argv[0] !== undefined && (ASSIGN.test(argv[0]) || FLOW.has(argv[0]))) argv.shift();
335  for (;;) {
336    const first = argv[0] ?? '';
337    if (first === 'timeout') argv = argv.slice(2);
338    else if (['time', 'nice', 'sudo', 'nohup'].includes(first)) argv = argv.slice(1);
339    else if (first.endsWith('/env') || (first === 'env' && argv.length > 1 && !argv[1]?.startsWith('-') && argv[1] !== '|')) {
340      argv = argv.slice(1);
341      while (argv[0] !== undefined && ASSIGN.test(argv[0])) argv.shift();
342    } else break;
343  }
344  if ((argv[0] ?? '').includes('/') && !(argv[0] ?? '').startsWith('./')) argv[0] = (argv[0] ?? '').split('/').pop() ?? '';
345  if (argv[0] === 'git' && argv[1] === '-C') argv.splice(1, 2);
346  if (PYTHONS.has(argv[0] ?? '') && argv[1] === '-m' && argv[2] === 'pip') argv = ['pip', ...argv.slice(3)];
347  return argv;
348}
349
350function line(stem: string, tier: Line['tier'] = 'read', id = 'bash.more'): SegLine {
351  return { glyph: '→', id, tier, stem };
352}
353
354function scriptName(path: string): string {
355  return path.split('/').pop() ?? path;
356}
357
358const NPM_TASKS: Record<string, string> = {
359  lint: 'Checking the code for mistakes',
360  typecheck: 'Checking the code for mistakes',
361  'type-check': 'Checking the code for mistakes',
362  format: "Tidying the code's layout",
363  dev: 'Starting the app',
364  start: 'Starting the app',
365  serve: 'Starting the app',
366  preview: 'Starting the app',
367};
368const HELPER_TOOLS: Record<string, string> = {
369  tsc: 'Checking the code for mistakes',
370  eslint: 'Checking the code for mistakes',
371  prettier: "Tidying the code's layout",
372  vite: 'Starting the app',
373};
374
375/** Common commands the original rules had no sentence for. */
376function moreLine(argv: string[]): SegLine | null {
377  const [command = '', sub = '', third = ''] = argv;
378  const args = argv.slice(1);
379  if (args.length > 0 && args.every((a) => ['--version', '-v', '-V', '-version', 'version'].includes(a))) {
380    return line(`Checking which version of ${command} is installed`);
381  }
382  if (command === 'clear') return line('Clearing the screen');
383  if (FLOW.has(command) || ['for', 'while', 'until', 'done', 'fi', 'esac'].includes(command)) return null;
384  if (['tree'].includes(command)) return line('Looking around the project');
385  if (['less', 'more', 'bat'].includes(command)) return line('Looking at existing files');
386  if (['du', 'df'].includes(command)) return line('Checking how much space things take up');
387  if (['file', 'stat'].includes(command)) return line("Looking at a file's details");
388  if (['which', 'type', 'whereis', 'realpath'].includes(command) || (command === 'command' && sub === '-v')) return line('Checking where something is on the computer');
389  if (['env', 'printenv'].includes(command)) return line("Checking the computer's settings");
390  if (['ps', 'lsof', 'pgrep', 'top'].includes(command)) return line('Checking which programs are running');
391  if (['date', 'uname', 'whoami', 'hostname', 'id', 'history', 'sw_vers', 'sysctl', 'system_profiler', 'uptime', 'arch', 'vm_stat', 'nproc', 'lscpu', 'free', 'ioreg'].includes(command)) return line('Checking details about the computer');
392  if (command === 'test' || command === '[' || command === '[[') return line('Checking whether something is there');
393  if (command === 'diff' || command === 'cmp') return line('Comparing two files');
394  if (command === 'sed' && args.some((a) => a === '-i' || a.startsWith('-i'))) return line('Changing text inside a file', 'action');
395  if (['sed', 'awk', 'cut', 'sort', 'uniq', 'tr', 'xargs', 'column', 'nl'].includes(command)) return line("Reading through a file's contents");
396  if (command === 'jq' || command === 'yq') return line('Pulling details out of a data file');
397  if (['kill', 'pkill', 'killall'].includes(command)) return line('Stopping a running program', 'action');
398  if (command === 'ping' || command === 'dig' || command === 'nslookup') return line('Checking the internet connection');
399  if (command === 'open' || command === 'xdg-open') return line('Opening something on your computer', 'action');
400  if (command === 'tar' && args.some((a) => /^-?[a-z]*x/.test(a))) return line('Unpacking an archive', 'action');
401  if (command === 'tar' || command === 'zip') return line('Packing files into an archive', 'action');
402  if (command === 'unzip' || command === 'gunzip') return line('Unpacking an archive', 'action');
403  if (command === 'ln') return line('Linking to a file or folder', 'action');
404  if (command === 'openssl') return line('Making or checking a security key', 'action');
405  if (['psql', 'sqlite3', 'mysql', 'redis-cli', 'mongosh'].includes(command)) return line('Looking at the database');
406  if (command === 'docker' || command === 'podman') {
407    if (['ps', 'logs', 'images', 'inspect'].includes(sub)) return line("Checking the app's containers");
408    if (sub === 'compose' && ['up', 'start'].includes(third)) return line("Starting the app's services", 'action');
409    if (sub === 'compose' && ['down', 'stop'].includes(third)) return line("Stopping the app's services", 'action');
410    return line("Managing the app's containers", 'action');
411  }
412  if (command === 'kubectl') return line(sub === 'get' || sub === 'describe' || sub === 'logs' ? "Checking the app's servers" : "Changing the app's servers", sub === 'get' ? 'read' : 'action');
413  if (command === 'terraform') return line(sub === 'plan' ? 'Planning changes to the servers' : 'Changing the servers', sub === 'plan' ? 'read' : 'action');
414  if (command === 'gh') return line(['list', 'view', 'status', 'diff', 'checks'].includes(third) ? 'Checking the project on GitHub' : 'Updating the project on GitHub', ['list', 'view', 'status', 'diff', 'checks'].includes(third) ? 'read' : 'action');
415  if (PACKAGE_MANAGERS.has(command)) {
416    const task = sub === 'run' ? third : sub;
417    if (NPM_TASKS[task] !== undefined) return line(NPM_TASKS[task] ?? '', 'action');
418    if (sub === 'outdated') return line('Checking for newer versions of its building blocks');
419    if (sub === 'ls' || sub === 'list' || sub === 'why') return line('Checking which building blocks are installed');
420    if (sub === 'run' && third !== '') return line(`Running the project's '${third}' task`, 'action');
421  }
422  if (['npx', 'pnpx', 'bunx'].includes(command) || (['pnpm', 'yarn', 'bun'].includes(command) && ['dlx', 'exec', 'x'].includes(sub))) {
423    const tool = command.endsWith('x') && command !== 'npx' ? sub : ['dlx', 'exec', 'x'].includes(sub) ? third : sub;
424    const name = tool.replace(/(.)@[^/]*$/, '$1');
425    return line(HELPER_TOOLS[name] ?? `Running a helper tool (${name})`, 'action');
426  }
427  if ((command === 'go' || command === 'cargo') && sub === 'run') return line('Starting the program', 'action');
428  if ((command === 'go' && sub === 'mod') || (command === 'cargo' && sub === 'update')) return line("Tidying the project's building blocks", 'action');
429  if ((command === 'cargo' && sub === 'fmt') || command === 'gofmt' || (command === 'go' && sub === 'fmt')) return line("Tidying the code's layout", 'action');
430  if (PYTHONS.has(command) && sub === '-m' && third === 'venv') return line('Setting up a separate Python workspace', 'action');
431  if (PYTHONS.has(command) && sub === '-m' && third !== '') return line(`Running a Python tool (${third})`, 'action');
432  if (['node', 'deno', 'bun', 'ruby', 'perl', 'php', 'python', 'python3'].includes(command)) {
433    if (['-e', '-c', '--eval', '-p'].includes(sub)) return line('Running a quick snippet of code', 'action');
434    if (args.includes('-')) return line('Running a quick snippet of code', 'action');
435    const file = args.find((a) => !a.startsWith('-') && a !== 'run');
436    if (file !== undefined) return line(`Running a script (${scriptName(file)})`, 'action');
437  }
438  if ((command === 'sh' || command === 'bash' || command === 'zsh') && sub !== '' && !sub.startsWith('-')) return line(`Running a script (${scriptName(sub)})`, 'action');
439  if (command.startsWith('./') || /\.(sh|py|js|rb)$/.test(command)) return line(`Running a script (${scriptName(command)})`, 'action');
440  return null;
441}
442
443function classifySegment(segment: string, level: Level): SegLine {
444  const argv = normalizeArgv(tokenize(segment));
445  const command = argv[0] ?? '';
446  const subcommand = argv[1] ?? '';
447  const kind = kindOfSegment(argv);
448
449  if (kind === 'test') return testLine(level, testBin(argv));
450
451  if (kind === 'install') {
452    if (subcommand === 'ci') {
453      return {
454        glyph: '→',
455        id: 'bash.install.ci',
456        tier: 'action',
457        stem:
458          level === 'plain'
459            ? "Setting up the project's building blocks"
460            : `Installing dependencies from lockfile (${command} ci)`,
461      };
462    }
463    const names = packageNames(argv);
464    return {
465      glyph: '→',
466      id: 'bash.install.pkg',
467      tier: 'action',
468      stem:
469        level === 'plain'
470          ? names === null
471            ? "Setting up the project's building blocks"
472            : 'Adding a ready-made tool to the project'
473          : names === null
474            ? 'Installing dependencies'
475            : `Installing package ${names}`,
476    };
477  }
478
479  if (kind === 'build') {
480    return {
481      glyph: '→',
482      id: 'bash.build',
483      tier: 'action',
484      stem: level === 'plain' ? 'Assembling the app' : 'Building the project',
485    };
486  }
487
488  if (kind === 'remove') {
489    const names = packageNames(argv);
490    return {
491      glyph: '→',
492      id: 'bash.install.pkg',
493      tier: 'action',
494      stem:
495        level === 'plain'
496          ? 'Removing a ready-made tool from the project'
497          : names === null
498            ? 'Removing a package'
499            : `Removing package ${names}`,
500    };
501  }
502
503  if (kind === 'read') {
504    if (command === 'grep' || command === 'rg') {
505      const terms = argv.filter((argument) => !argument.startsWith('-'));
506      return {
507        glyph: '→',
508        id: 'bash.search',
509        tier: 'read',
510        stem:
511          level === 'plain'
512            ? 'Searching through the project'
513            : `Searching for ${terms[1] ?? 'a pattern'}`,
514      };
515    }
516    if (command === 'ls') {
517      return {
518        glyph: '→',
519        id: 'bash.read',
520        tier: 'read',
521        stem: level === 'plain' ? 'Looking around the project' : 'Listing files',
522      };
523    }
524    if (command === 'git') return gitLine(argv, level);
525    if (level === 'plain') {
526      if (command === 'echo') {
527        return line(argv.slice(1).some((a) => a.startsWith('$')) ? "Checking the computer's settings" : 'Showing a message');
528      }
529      if (command === 'pwd') return line('Checking which folder it is working in');
530      if (command === 'which') return line('Checking where something is on the computer');
531      if (command === 'wc') return line('Counting what is in a file');
532      if (command === 'tail' && argv.includes('-f')) return line('Watching a log as it grows');
533    }
534    return {
535      glyph: '→',
536      id: 'bash.read',
537      tier: 'read',
538      stem:
539        level === 'plain'
540          ? 'Looking at existing files'
541          : `Reading with ${command || 'a file tool'}`,
542    };
543  }
544
545  if (command === 'git') return gitLine(argv, level);
546
547  if (command === 'mkdir') {
548    const target =
549      argv.slice(1).find((argument) => !argument.startsWith('-')) ?? '';
550    return {
551      glyph: '→',
552      id: 'bash.mkdir',
553      tier: 'action',
554      stem:
555        level === 'plain'
556          ? 'Creating a folder'
557          : `Creating folder ${target}`.trim(),
558    };
559  }
560
561  if (['cp', 'mv', 'touch', 'chmod'].includes(command)) {
562    const target =
563      argv.slice(1).find((argument) => !argument.startsWith('-')) ?? 'files';
564    const verb =
565      command === 'cp'
566        ? 'Copying'
567        : command === 'mv'
568          ? 'Moving'
569          : command === 'touch'
570            ? 'Creating'
571            : 'Changing permissions on';
572    return {
573      glyph: '→',
574      id: 'bash.fileops',
575      tier: 'action',
576      stem:
577        level === 'plain' ? 'Organizing project files' : `${verb} ${target}`,
578    };
579  }
580
581  if (command === 'curl' || command === 'wget') {
582    const url =
583      argv.find((argument) => argument.startsWith('http')) ?? 'a URL';
584    return {
585      glyph: '→',
586      id: 'bash.fetch',
587      tier: 'action',
588      stem:
589        level === 'plain'
590          ? 'Fetching something from the internet'
591          : `Fetching ${url}`,
592    };
593  }
594
595  const more = moreLine(argv);
596  if (more !== null) return more;
597  if (['for', 'while', 'until'].includes(command)) {
598    return { ...line('Repeating a step', 'read', 'bash.flow') };
599  }
600  return genericLine(segment, level);
601}
602
603function normalizedHint(hint: DangerHint | undefined): DangerHint | null {
604  if (
605    hint === undefined ||
606    !Number.isFinite(hint.count) ||
607    hint.count < 0 ||
608    typeof hint.cap !== 'boolean'
609  ) {
610    return null;
611  }
612  return { count: Math.floor(hint.count), cap: hint.cap };
613}
614
615function countText(hint: DangerHint): string {
616  if (hint.cap) {
617    return `${hint.count}+ ${
618      hint.count === 1 ? 'item' : 'items'
619    }`;
620  }
621  return counted(hint.count, 'item');
622}
623
624function dangerLine(
625  command: string,
626  level: Level,
627  hint: DangerHint | undefined,
628): Line | null {
629  const danger = detectDanger(command);
630  if (danger === null) return null;
631  const measured = normalizedHint(hint);
632  let stem: string;
633  let facts: string | undefined;
634
635  if (danger.reason === 'delete') {
636    if (measured === null && danger.recursive === true) {
637      if (level === 'developer') {
638        stem = `Deleting ${danger.target}`;
639        facts = '(entire folder)';
640      } else {
641        stem = `Deleting '${danger.target}' and everything inside it`;
642      }
643    } else if (measured === null) {
644      stem =
645        level === 'plain'
646          ? `Deleting '${danger.target}'`
647          : `Deleting ${danger.target}`;
648    } else if (danger.recursive !== true) {
649      stem =
650        level === 'plain'
651          ? `Deleting '${danger.target}'`
652          : `Deleting ${danger.target}`;
653      facts = `(${countText(measured)})`;
654    } else if (level === 'developer') {
655      stem = `Deleting ${danger.target}`;
656      facts = `(${countText(measured)})`;
657    } else {
658      stem = `Deleting '${danger.target}' and the ${countText(measured)} inside it`;
659    }
660  } else if (danger.reason === 'delete-untracked') {
661    if (measured === null) {
662      stem =
663        level === 'plain'
664          ? 'Permanently deleting files the project is not tracking'
665          : 'Permanently deleting untracked files';
666    } else if (level === 'plain') {
667      stem = `Permanently deleting the ${countText(measured)} the project is not tracking`;
668    } else {
669      stem = 'Permanently deleting untracked files';
670      facts = `(${countText(measured)})`;
671    }
672  } else if (danger.reason === 'overwrite-history') {
673    stem =
674      level === 'plain'
675        ? 'Replacing the shared online copy of this work; older versions there are lost'
676        : `Overwriting the online history (${danger.target})`;
677  } else if (danger.reason === 'empty') {
678    stem = level === 'plain' ? `Emptying '${danger.target}'` : `Emptying ${danger.target}`;
679  } else if (danger.reason === 'empty-read') {
680    stem =
681      level === 'plain'
682        ? `Emptying '${danger.target}' before it is read, so what was in it is lost`
683        : `Emptying ${danger.target} before it is read`;
684  } else if (danger.reason === 'overwrite') {
685    stem =
686      level === 'plain'
687        ? `Writing over '${danger.target}'; anything already in it is lost`
688        : `Overwriting ${danger.target}`;
689  } else if (danger.reason === 'permissions') {
690    if (level === 'developer') {
691      stem = `Changing permissions on ${danger.target}`;
692      if (danger.recursive === true) facts = '(entire folder)';
693    } else {
694      stem =
695        danger.recursive === true
696          ? `Changing who can open or change '${danger.target}' and everything inside it`
697          : `Changing who can open or change '${danger.target}'`;
698    }
699  } else if (danger.reason === 'drop-data') {
700    stem =
701      level === 'plain'
702        ? `Permanently erasing stored data (${danger.target})`
703        : `Dropping data (${danger.target})`;
704  } else if (danger.reason === 'discard-worktree') {
705    stem =
706      level === 'plain'
707        ? `Throwing away unsaved edits in ${danger.target}`
708        : `Discarding working-copy changes (${danger.target})`;
709  } else if (level === 'developer') {
710    stem = `Throwing away recent changes (${danger.target})`;
711  } else {
712    const revision = /^HEAD~(\d+)$/.exec(danger.target);
713    const count = revision === null ? null : Number(revision[1] ?? 0);
714    if (
715      danger.target === 'working copy' ||
716      danger.target === 'HEAD' ||
717      count === 0
718    ) {
719      stem = 'Throwing away every unsaved edit in the project';
720    } else if (count !== null) {
721      stem =
722        count === 1
723          ? 'Throwing away the last saved checkpoint and any unsaved edits'
724          : `Throwing away the last ${count} saved checkpoints and any unsaved edits`;
725    } else {
726      stem = `Throwing away unsaved edits and moving the work to ${danger.target}`;
727    }
728  }
729
730  return {
731    glyph: '⚠',
732    id:
733      danger.reason === 'delete' || danger.reason === 'delete-untracked'
734        ? 'bash.danger.delete'
735        : danger.reason === 'overwrite-history'
736          ? 'bash.danger.history'
737          : danger.reason === 'permissions'
738            ? 'bash.danger.permissions'
739            : danger.reason === 'drop-data'
740              ? 'bash.danger.data'
741              : 'bash.danger.discard',
742    stem,
743    ...(facts === undefined ? {} : { facts }),
744    text: lineText(stem, facts),
745    severity: 'danger',
746    tier: 'danger',
747    raw: command,
748  };
749}
750
751export function translateBash(
752  command: string,
753  level: Level,
754  hint?: DangerHint,
755): Line {
756  const danger = dangerLine(command, level, hint);
757  if (danger !== null) return danger;
758
759  // Moving to a folder or setting a variable is not a step anyone asked for:
760  // `cd app && npm test` is a test run, not "+1 more step".
761  const stages: Array<{ text: string; piped: boolean }> = [];
762  for (const pipeline of splitPipelines(stripHeredocs(command).command)) {
763    splitSegments(stripComment(pipeline)).forEach((text, index) => stages.push({ text, piped: index > 0 }));
764  }
765  const steps = stages.filter((stage) => !NAVIGATION.has(tokenize(stage.text)[0] ?? ''));
766  const chosen = steps.length > 0 ? steps : stages;
767  const { lines: classified, savedTo } = classifyStages(chosen, level);
768  const actions = classified.filter((segment) => segment.tier === 'action');
769  // Filler (a pause, staging before a commit, an unknown script) never names
770  // a chain when a step that says what the chain is for comes later.
771  const meaningful = actions.filter(
772    (segment) => segment.id !== 'bash.generic' && segment.id !== 'bash.git.add',
773  );
774  const picked =
775    meaningful[0] ??
776    actions[0] ??
777    classified[0] ??
778    genericLine(command.trim(), level);
779  const extraCount = Math.max(0, actions.length - 1);
780  const facts =
781    extraCount === 0
782      ? picked.facts
783      : `(+${extraCount} more ${plural(extraCount, 'step')})`;
784  // Output sent to a file is a short fact on the step that made it.
785  const saved = facts === undefined && savedTo !== undefined ? `(${savedTo})` : facts;
786  return asLine(
787    command,
788    {
789      ...picked,
790      ...(saved === undefined ? {} : { facts: saved }),
791    },
792    actions.length === 0 ? picked.tier : 'action',
793  );
794}
795
796const CONTENT_COMMANDS = new Set(['cat', 'echo', 'printf']);
797
798function fileName(target: string): string | undefined {
799  const clean = target.replace(/^["']|["']$/g, '');
800  if (clean === '' || /[$`]/.test(clean)) return undefined;
801  return clean.split('/').filter(Boolean).pop();
802}
803
804function writeLine(target: string, kind: 'write' | 'append' | 'empty'): SegLine {
805  const name = fileName(target);
806  const stem =
807    kind === 'append' ? 'Adding to a file' : kind === 'empty' ? 'Emptying a file' : 'Writing a file';
808  return { glyph: '✎', id: 'bash.write', tier: 'action', stem, ...(name === undefined ? {} : { facts: `(${name})` }) };
809}
810
811/**
812 * Classifies each step, reading shell writes the way the Write tool's rows
813 * read: `cat > f <<EOF`, `echo … > f` and `tee f` write a file (never "showing"
814 * one), and any other command whose output goes to a file stays that command.
815 * Whether the file existed before is never claimed either way.
816 */
817function classifyStages(
818  stages: ReadonlyArray<{ text: string; piped: boolean }>,
819  level: Level,
820): { lines: SegLine[]; savedTo?: string } {
821  const lines: SegLine[] = [];
822  let savedTo: string | undefined;
823  let producerIsContent = false;
824  for (const stage of stages) {
825    const scan = scanCommand(stage.text);
826    const words = normalizeArgv(tokenize(scan.stripped));
827    const output = scan.redirects.find((redirect) => redirect.fd === '1' || redirect.fd === '&');
828    const errors = scan.redirects.find((redirect) => redirect.fd === '2');
829    const first = words[0] ?? '';
830    const writesNothing = words.length === 0 || first === ':' || first === 'true' || (first === 'cat' && words[1] === '/dev/null');
831    const isContent = writesNothing || CONTENT_COMMANDS.has(first);
832    if (!stage.piped) producerIsContent = isContent;
833    if (first === 'tee') {
834      const target = words.slice(1).find((word) => !word.startsWith('-') && !isNullSink(word));
835      // `tee /dev/stderr` only passes the output along; it is not a step.
836      if (target === undefined) continue;
837      if (!stage.piped || producerIsContent) {
838        // `tee f <<EOF`, `echo … | tee f`: the file is the point of the step.
839        if (stage.piped && lines.length > 0) lines.pop();
840        const append = words.some((word) => word === '--append' || /^-[a-z]*a/.test(word));
841        lines.push(writeLine(target, append ? 'append' : 'write'));
842      } else {
843        savedTo ??= `output saved to ${fileName(target) ?? 'a file'}`;
844      }
845      continue;
846    }
847    if (first === 'truncate' && truncatesToNothing(words)) {
848      // A named file being emptied is a danger line already; a scratch one is routine.
849      lines.push(writeLine(words[words.length - 1] ?? '', 'empty'));
850      continue;
851    }
852    if (output !== undefined && isContent) {
853      const kind = writesNothing ? 'empty' : output.append ? 'append' : 'write';
854      lines.push(writeLine(output.target, kind));
855      continue;
856    }
857    if (output !== undefined) savedTo ??= `output saved to ${fileName(output.target) ?? 'a file'}`;
858    else if (errors !== undefined) savedTo ??= `errors saved to ${fileName(errors.target) ?? 'a file'}`;
859    lines.push(classifySegment(stage.text, level));
860  }
861  return savedTo === undefined ? { lines } : { lines, savedTo };
862}
863
864function hasKind(command: string, expected: SegmentKind): boolean {
865  return splitSegments(command).some(
866    (segment) => kindOfSegment(tokenize(segment)) === expected,
867  );
868}
869
870export function isTestCommand(command: string): boolean {
871  return hasKind(command, 'test');
872}
873
874export function isInstallCommand(command: string): boolean {
875  return hasKind(command, 'install');
876}
877
878export function isBuildCommand(command: string): boolean {
879  return hasKind(command, 'build');
880}
881
engine/src/rules/outcome-rules.ts 325 lines
1import type { Level, Line, CodevibezEvent } from '../types.ts';
2import { counted, plural } from '../words.ts';
3import { tokenize } from '../bash.ts';
4import {
5  isBuildCommand,
6  isInstallCommand,
7  isTestCommand,
8} from './bash-rules.ts';
9
10export interface TestCounts {
11  failed: number;
12  passed: number;
13}
14
15function numberAt(match: RegExpMatchArray, index: number): number {
16  return Number(match[index] ?? 0);
17}
18
19function pairedCounts(output: string): TestCounts | null {
20  const failedFirst = output.match(
21    /(\d+)\s+fail(?:ed|ing)\b[\s\S]{0,200}?(\d+)\s+pass(?:ed|ing)\b/i,
22  );
23  if (failedFirst !== null) {
24    return {
25      failed: numberAt(failedFirst, 1),
26      passed: numberAt(failedFirst, 2),
27    };
28  }
29
30  const passedFirst = output.match(
31    /(\d+)\s+pass(?:ed|ing)\b[\s\S]{0,200}?(\d+)\s+fail(?:ed|ing)\b/i,
32  );
33  if (passedFirst !== null) {
34    return {
35      failed: numberAt(passedFirst, 2),
36      passed: numberAt(passedFirst, 1),
37    };
38  }
39
40  return null;
41}
42
43function cargoCounts(output: string): TestCounts | null {
44  const lines = output
45    .split('\n')
46    .filter((line) => /\btest result:/i.test(line));
47  if (lines.length === 0) return null;
48
49  let failed = 0;
50  let passed = 0;
51  let matched = false;
52  for (const line of lines) {
53    const passedMatch = line.match(/(\d+)\s+passed\b/i);
54    const failedMatch = line.match(/(\d+)\s+failed\b/i);
55    if (passedMatch !== null || failedMatch !== null) matched = true;
56    passed += passedMatch === null ? 0 : numberAt(passedMatch, 1);
57    failed += failedMatch === null ? 0 : numberAt(failedMatch, 1);
58  }
59  return matched ? { failed, passed } : null;
60}
61
62function goCounts(output: string): TestCounts | null {
63  const passed = output.match(/^\s*--- PASS:/gm)?.length ?? 0;
64  const failed = output.match(/^\s*--- FAIL:/gm)?.length ?? 0;
65  return passed + failed === 0 ? null : { failed, passed };
66}
67
68// Node's built-in runner ends with summary lines, `# pass 1` (TAP) or
69// `ℹ pass 1` (spec): the word comes before the number.
70function nodeCounts(output: string): TestCounts | null {
71  const passed = output.match(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/m);
72  const failed = output.match(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/m);
73  if (passed === null || failed === null) return null;
74  return { failed: numberAt(failed, 1), passed: numberAt(passed, 1) };
75}
76
77// Python's unittest: `Ran 3 tests in 0.01s`, then `OK` or
78// `FAILED (failures=1, errors=1, skipped=2)`.
79function unittestCounts(output: string): TestCounts | null {
80  const ran = output.match(/^Ran (\d+) tests? in /m);
81  const status = output.match(/^(OK|FAILED)\b(?:\s*\(([^)]*)\))?/m);
82  if (ran === null || status === null) return null;
83  const detail = status[2] ?? '';
84  const field = (name: string): number =>
85    Number(new RegExp(`\\b${name}=(\\d+)`).exec(detail)?.[1] ?? 0);
86  const failed = field('failures') + field('errors') + field('unexpected successes');
87  const skipped = field('skipped') + field('expected failures');
88  return { failed, passed: Math.max(0, numberAt(ran, 1) - failed - skipped) };
89}
90
91export function parseTestCounts(output: string): TestCounts | null {
92  const lines = output.split('\n');
93  const authoritative = lines.filter(
94    (line) =>
95      /^\s*Tests(?::|\s)/i.test(line) &&
96      !/^\s*Test (?:Files|Suites)\b/i.test(line) &&
97      !/^\s*Suites\b/i.test(line),
98  );
99  for (let index = authoritative.length - 1; index >= 0; index -= 1) {
100    const line = authoritative[index];
101    if (line === undefined) continue;
102    const counts = pairedCounts(line);
103    if (counts !== null) return counts;
104  }
105
106  const cargo = cargoCounts(output);
107  if (cargo !== null) return cargo;
108
109  const go = goCounts(output);
110  if (go !== null) return go;
111
112  const node = nodeCounts(output);
113  if (node !== null) return node;
114
115  const unittest = unittestCounts(output);
116  if (unittest !== null) return unittest;
117
118  const filtered = lines
119    .filter(
120      (line) =>
121        !/^\s*Test (?:Files|Suites)\b/i.test(line) &&
122        !/^\s*Suites\b/i.test(line),
123    )
124    .join('\n');
125  const paired = pairedCounts(filtered);
126  if (paired !== null) return paired;
127
128  if (/\d+\s+fail/i.test(output)) return null;
129  const passed = output.match(
130    /(?:Tests\s*:?\s*)?(\d+)\s+pass(?:ed|ing)\b/i,
131  );
132  return passed === null
133    ? null
134    : { failed: 0, passed: numberAt(passed, 1) };
135}
136
137function resultLine(
138  glyph: Line['glyph'],
139  severity: Line['severity'],
140  stem: string,
141  raw: string,
142  facts?: string,
143): Line {
144  const id =
145    severity === 'failure'
146      ? /checks failed/.test(stem)
147        ? 'out.test.fail'
148        : 'out.fail'
149      : /checks/i.test(stem)
150        ? 'out.test.pass'
151        : stem === 'Set up finished'
152          ? 'out.install.ok'
153          : 'out.build.ok';
154  return {
155    glyph,
156    id,
157    severity,
158    tier: 'action',
159    stem,
160    ...(facts === undefined ? {} : { facts }),
161    text: facts === undefined ? stem : `${stem} ${facts}`,
162    raw,
163  };
164}
165
166function runnerName(command: string): string {
167  const argv = tokenize(command);
168  const executable = argv[0] ?? '';
169  if (executable === 'npx') return argv[1] ?? 'test';
170  if (
171    ['npm', 'pnpm', 'yarn', 'bun'].includes(executable) &&
172    (argv[1] === 'test' || argv[1] === 'run')
173  ) {
174    return executable;
175  }
176  const fileName = executable.split('/').pop() ?? executable;
177  return fileName.replace(/\.[^.]+$/, '') || 'test';
178}
179
180function firstOutputLine(event: CodevibezEvent): string {
181  const stderr =
182    typeof event.outcome?.stderr === 'string' ? event.outcome.stderr : '';
183  const stdout =
184    typeof event.outcome?.stdout === 'string' ? event.outcome.stdout : '';
185  return (
186    stderr.split('\n').find((line) => line.trim() !== '') ??
187    stdout.split('\n').find((line) => line.trim() !== '') ??
188    ''
189  )
190    .trim()
191    .slice(0, 80);
192}
193
194function plainFailureStem(output: string): string {
195  if (
196    /cannot find module|module not found|no such file|enoent\b/i.test(output)
197  ) {
198    return 'Something needed for that step could not be found';
199  }
200  if (/permission denied|eacces\b|eperm\b/i.test(output)) {
201    return 'That step was not allowed to access something it needed';
202  }
203  if (/eaddrinuse\b|address already in use|port .*in use/i.test(output)) {
204    return 'That step could not start because its connection point is already in use';
205  }
206  if (/syntaxerror\b|syntax error|unexpected token/i.test(output)) {
207    return 'That step could not run because its instructions were malformed';
208  }
209  if (
210    /enetunreach\b|econnrefused\b|network|dns|timed? out|fetch failed/i.test(
211      output,
212    )
213  ) {
214    return 'That step could not reach the network';
215  }
216  return 'Something went wrong while running that step';
217}
218
219export function translateOutcome(
220  event: CodevibezEvent,
221  level: Level,
222): Line | null {
223  try {
224    const outcome = event.outcome;
225    if (outcome === undefined) return null;
226
227    const raw =
228      event.raw !== undefined &&
229      typeof event.raw === 'object' &&
230      event.raw !== null &&
231      !Array.isArray(event.raw)
232        ? event.raw
233        : {};
234    const command =
235      typeof raw.command === 'string' ? raw.command : '';
236    const stdout =
237      typeof outcome.stdout === 'string' ? outcome.stdout : '';
238    const stderr =
239      typeof outcome.stderr === 'string' ? outcome.stderr : '';
240    const output = `${stdout}\n${stderr}`;
241    const rawOutput = output.slice(0, 400);
242    const test = command !== '' && isTestCommand(command);
243
244    if (test) {
245      const counts = parseTestCounts(output);
246      if (counts !== null) {
247        if (counts.failed > 0) {
248          const total = counts.failed + counts.passed;
249          const noun =
250            level === 'developer'
251              ? plural(total, 'test')
252              : plural(total, 'check');
253          const stem = `${counts.failed} of ${total} ${noun} failed`;
254          return resultLine('✗', 'failure', stem, rawOutput);
255        }
256        if (outcome.ok === true) {
257          return resultLine(
258            '✓',
259            'success',
260            'All checks pass',
261            rawOutput,
262            `(${counted(counts.passed, 'check')})`,
263          );
264        }
265      }
266
267      const hasFailureSignal = /\bfail(?:ed|ing|ure)?\b|error/i.test(output);
268      if (outcome.ok !== true || hasFailureSignal) {
269        const stem =
270          level === 'plain'
271            ? 'The checks failed; codevibez could not read how many'
272            : `The ${runnerName(
273                command,
274              )} checks failed; codevibez could not read how many`;
275        return resultLine('✗', 'failure', stem, rawOutput);
276      }
277
278      return resultLine('✓', 'success', 'Checks finished', rawOutput);
279    }
280
281    if (outcome.ok !== true) {
282      if (level === 'developer') {
283        const first = firstOutputLine(event);
284        return resultLine(
285          '✗',
286          'failure',
287          'Something went wrong while running that step',
288          rawOutput,
289          first === '' ? '(1 failure)' : `(${first})`,
290        );
291      }
292      return resultLine(
293        '✗',
294        'failure',
295        plainFailureStem(output),
296        rawOutput,
297        '(1 failure)',
298      );
299    }
300
301    if (command !== '' && isInstallCommand(command)) {
302      const installed = output.match(/added\s+(\d+)\s+packages?/i);
303      const installedCount =
304        installed === null ? null : numberAt(installed, 1);
305      return resultLine(
306        '✓',
307        'success',
308        'Set up finished',
309        rawOutput,
310        installedCount === null
311          ? undefined
312          : `(${counted(installedCount, 'package')})`,
313      );
314    }
315
316    if (command !== '' && isBuildCommand(command)) {
317      return resultLine('✓', 'success', 'Build finished', rawOutput);
318    }
319
320    return null;
321  } catch {
322    return null;
323  }
324}
325
engine/src/words.ts 16 lines
1export function plural(
2  count: number,
3  singular: string,
4  pluralForm = `${singular}s`,
5): string {
6  return count === 1 ? singular : pluralForm;
7}
8
9export function counted(
10  count: number,
11  singular: string,
12  pluralForm = `${singular}s`,
13): string {
14  return `${count} ${plural(count, singular, pluralForm)}`;
15}
16
engine/src/prompt.ts 7 lines
1export function formatPrompt(text: string, limit = 80): string {
2  const collapsed = text.replace(/\s+/g, ' ').trim();
3  return collapsed.length > limit
4    ? `${collapsed.slice(0, limit - 1)}…`
5    : collapsed;
6}
7
engine/src/rules/file-rules.ts 145 lines
1import type { Level, Line } from '../types.ts';
2
3function baseName(path: unknown): string {
4  if (typeof path !== 'string' || path.length === 0) return 'a file';
5  return path.split(/[\\/]/).pop() || 'a file';
6}
7
8function rawText(raw: Record<string, unknown>): string {
9  try {
10    return JSON.stringify(raw).slice(0, 200);
11  } catch {
12    return '[unavailable tool input]';
13  }
14}
15
16function makeLine(
17  raw: Record<string, unknown>,
18  glyph: Line['glyph'],
19  tier: Line['tier'],
20  stem: string,
21  facts?: string,
22  id = 'file.edit',
23): Line {
24  return {
25    glyph,
26    id,
27    stem,
28    ...(facts === undefined ? {} : { facts }),
29    text: facts === undefined ? stem : `${stem} ${facts}`,
30    tier,
31    severity: 'info',
32    raw: rawText(raw),
33  };
34}
35
36export function translateFileTool(
37  tool: string,
38  raw: Record<string, unknown>,
39  level: Level,
40): Line | null {
41  switch (tool) {
42    case 'Read':
43      return makeLine(
44        raw,
45        '→',
46        'read',
47        level === 'plain'
48          ? 'Looking at existing code'
49          : `Reading ${baseName(raw.file_path)}`,
50        undefined,
51        'file.read',
52      );
53
54    case 'Grep':
55      return makeLine(
56        raw,
57        '→',
58        'read',
59        level === 'plain'
60          ? 'Searching through the code'
61          : `Searching for ${
62              typeof raw.pattern === 'string'
63                ? `'${raw.pattern}'`
64                : 'a pattern'
65            }`,
66        undefined,
67        'file.search',
68      );
69
70    case 'Glob':
71      return makeLine(
72        raw,
73        '→',
74        'read',
75        level === 'plain'
76          ? 'Searching through the code'
77          : `Finding files matching ${
78              typeof raw.pattern === 'string' ? raw.pattern : 'a pattern'
79            }`,
80        undefined,
81        'file.search',
82      );
83
84    case 'Write': {
85      const exists = raw.codevibez_exists === true;
86      if (level === 'plain') {
87        return exists
88          ? makeLine(
89              raw,
90              '✎',
91              'action',
92              "Rewriting one of the app's files",
93              undefined,
94              'file.write.over',
95            )
96          : makeLine(
97              raw,
98              '✎',
99              'action',
100              'Writing new code',
101              '(1 new file)',
102              'file.write.new',
103            );
104      }
105      return exists
106        ? makeLine(
107            raw,
108            '✎',
109            'action',
110            `Rewriting ${baseName(raw.file_path)}`,
111            undefined,
112            'file.write.over',
113          )
114        : makeLine(
115            raw,
116            '✎',
117            'action',
118            `Writing ${baseName(raw.file_path)}`,
119            '(new file)',
120            'file.write.new',
121          );
122    }
123
124    case 'Edit':
125    case 'MultiEdit':
126      return level === 'plain'
127        ? makeLine(
128            raw,
129            '✎',
130            'action',
131            "Updating the app's code",
132            '(1 file)',
133          )
134        : makeLine(
135            raw,
136            '✎',
137            'action',
138            `Updating ${baseName(raw.file_path)}`,
139          );
140
141    default:
142      return null;
143  }
144}
145
engine/src/types.ts 40 lines
1export type Agent = 'claude-code' | 'codex';
2export type EventKind = 'prompt' | 'tool.pre' | 'tool.post' | 'agent.done';
3export type Level = 'plain' | 'developer';
4export type Tier = 'read' | 'action' | 'danger';
5
6export interface CodevibezOutcome {
7  ok: boolean;
8  exitCode?: number;
9  stdout?: string;
10  stderr?: string;
11}
12
13export interface CodevibezEvent {
14  v: 1;
15  ts: string;
16  session: string;
17  agent: Agent;
18  kind: EventKind;
19  tool?: string;
20  raw?: Record<string, unknown>;
21  outcome?: CodevibezOutcome;
22  text?: string;
23}
24
25export interface Line {
26  glyph: '→' | '✎' | '⚠' | '✗' | '✓' | '●';
27  text: string;
28  stem?: string;
29  facts?: string;
30  level?: Level;
31  severity: 'info' | 'success' | 'failure' | 'danger';
32  tier: Tier;
33  raw: string;
34  generic?: true;
35  id?: string;
36  mood?: 'neutral' | 'busy' | 'win' | 'oops' | 'alarm' | 'kickoff';
37  accent?: 'dim' | 'normal' | 'bold';
38  pulse?: number;
39}
40
hooks/redact.ts 43 lines
1/**
2 * English lines quote bits of what the agent ran: a deletion's target, a
3 * search query, a helper's task. Those bits can carry a secret, so every line
4 * is scrubbed before it is drawn.
5 *
6 * This is deliberately best-effort, ported from the July event-log privacy
7 * fix: common carriers are scrubbed, but arbitrary commands and text can
8 * encode secrets in forms no pattern can recognize. Nothing here is written
9 * anywhere; the mod keeps no log.
10 */
11const REDACTED = '[REDACTED]';
12
13/** Scrub common secret carriers from one string. */
14export function redactSecrets(value: string): string {
15  try {
16    return value
17      .replace(
18        /(\b(?:proxy-)?authorization\s*:\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\r\n"']+)/gi,
19        `$1${REDACTED}`,
20      )
21      .replace(
22        /\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^@\s/]+@/gi,
23        `$1${REDACTED}@`,
24      )
25      .replace(
26        /((?:^|\s)--(?:access[-_]?token|auth[-_]?token|token|api[-_]?key|password|secret|client[-_]?secret)\b(?:\s*=\s*|\s+))(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi,
27        `$1${REDACTED}`,
28      )
29      .replace(
30        /(\b(?:[a-z][a-z0-9_-]*?)?(?:token|password|secret|api[_-]?key)\b\s*=\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi,
31        `$1${REDACTED}`,
32      )
33      // Known token formats are useful coverage, but still cannot make this
34      // best-effort scrubber complete.
35      .replace(
36        /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]+|xox[bpar]-[A-Za-z0-9_-]+|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]+|glpat-[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*)(?![A-Za-z0-9_-])/g,
37        REDACTED,
38      );
39  } catch {
40    return REDACTED;
41  }
42}
43