SLOPSHOPPER

side-effect-ledger

A ledger of what a git diff never shows: pushes, publishes, deploys, pull requests, releases, API writes and messages sent, each with its outcome, how it would…

newpaneguardcommandtoaststatus
★ 1v0.1.11MITupdated 2026-10-16simota/claude-mods/mods/side-effect-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-effect-ledger
│ ┃ 外部操作の台帳 ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ✓ [push] git push --force origin main │ side-effect-ledger │ │ ┃ 10/9/2025, 8:53:20 AM · 成功 ⏺ Read(src/auth.ts) │ 📒 git push --force origin main — 取り消し: │ │ ┃ ↩ 手作業で戻す: 押し出したコミットを orig… ⎿ Read 6 lines │ 押し出したコミットを origin の以前の位置へ戻す: git push │ │ ┃ □ 取消未確認 [確認済みにする] [不要] ⏺ Update(src/auth.ts) │ --force-with-lease origin <以前のsha>:<ブランチ>( │ │ ┃ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ 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 │ │ › /ledger │ ⎿ side-effect-ledger: 📒 記録 1件(取り消し方法が未確認 1件) │ ⎿ side-effect-ledger: - 10/9/2025, 8:53:20 AM ✓ git push --force o │ ⎿ side-effect-ledger: 取り消し(手作業で戻す): 押し出したコミットを origin の以前の位置へ戻 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ side-effect-ledger: 📒 1 · 取消未確認 1

Draws

Pane · 外部操作の台帳
✓ [push] git push --force origin main 10/9/2025, 8:53:20 AM · 成功 ↩ 手作業で戻す: 押し出したコミットを origin の以前の位置へ… □ 取消未確認 [確認済みにする] [不要]
README

claude-mods

English | 日本語

Personal Claude Code mods (function-hook plugins), published as one marketplace.

ModWhat it does
agent-paneLive pane of the session's subagents: running ones with elapsed time, finished ones with result and duration
done-gateFlags unverified edits, stops repeated failing commands, and blocks git --no-verify
sandbox-lensTallies sandbox denials seen in Bash output and reports them with /sandbox-report
skill-hintSuggests up to three matching skills above the prompt from what was typed; never invokes them.
todo-agentCollects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane.
context-meterBand above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost
tool-rowRedraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat
image-previewThumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane
self-auditAfter each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt
context-dietTrims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved
browser-camLive pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions
prompt-coachHolds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default
loopbackFeeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default
zukanError book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time
pr-lampThe current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt
fog-mapExploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map
assertion-guardCatches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit
negative-spaceWhen an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space
mutation-tap/mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch
perf-delta/perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise
handoff-capsuleBefore a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back
port-lanternThe dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports
artifact-shelfA shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time
focus-hudOne status line: goal, target, what is running and for how long, and what the session waits on; /focus
side-effect-ledgerA ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger
trust-labelLabels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust
visual-diffCompares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff
journey-traceRecords Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft
hit-rateGives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default
token-lensChecks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector>

Install

At the prompt of a terminal session:

/plugin install <mod> --marketplace simota/claude-mods

From a shell:

claude plugin install <mod> --marketplace simota/claude-mods

This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).

Usage

Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:

echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin

A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.

agent-pane

  • /agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).
  • In a fullscreen terminal the pane opens by itself once per session, on the first Agent call (a reload does not reopen it).
  • The status line shows agents: N running while any run.

done-gate

Works on its own, no command:

  • A turn that edited files without a passing test, lint, typecheck or build afterwards leaves a band: 未検証の変更があります. It stays until a check succeeds or 閉じる is pressed. Only the program a command runs counts (ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).
  • The second identical failure of the same Bash command tells Claude to stop retrying and diagnose the root cause (again after the command has succeeded in between).
  • Any git command with --no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected.

sandbox-lens

  • /sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each.
  • /sandbox-report reset clears the record.
  • Hints only: it never changes sandbox settings. A loopback or private address keeps its port in the hint (127.0.0.1:3000), since an entry without a port opens every port.
  • A <sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.

skill-hint

After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.

todo-agent

  • Lines starting with #TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored.
  • /todos opens the pane of this project's open items, each with a 完了 button.
  • /todos all lists every project's open items as text, marked as stored data.
  • The status line shows TODO(agent): N while the project has open items.

context-meter

A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.

OptionDefault
bandtrueDraw the band (samples are recorded either way)

tool-row

Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.

OptionDefault
compacttrueOff leaves every row and result as Claude Code draws them

image-preview

  • Images a session writes, reads, or that a Bash command produces (png, jpg, jpeg, webp, gif) get a thumbnail under that tool's row in the terminal (kitty graphics: Ghostty, kitty).
  • /images opens a pane, newest first; h/l step through them.
  • Non-PNG files are converted with macOS sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • A Bash command and its output are searched for image paths in time linear in their length, before the result goes back. A path split at spaces (/tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.
OptionDefault
inlinetrueThumbnail under the tool row
maxRows12Rows a thumbnail may take, 1 to 60

self-audit

After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.

OptionDefault
enabledtrue
minChars200Shorter replies are not audited

Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.

context-diet

Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.

  • /context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged.
  • /context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.
OptionDefault
enabledfalse
keepComma list of names or prefix* never dropped; wins over drop
dropComma list of names or prefix* always dropped

browser-cam

  • While Claude drives a browser (Claude in Chrome, Claude Browser, or a remote device's browser), a pane shows the latest screenshot, the page title and URL, and the last 8 actions with ✓/✗. The URL comes only from the navigate call or the tab tools' own header, never from page text. Text typed or filled in is shown as its length only (type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked.
  • /cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • The desktop app shows the title and URL, a caption and the actions, without the picture.
OptionDefault
autoOpentrueOpen on the first browser call (fullscreen terminal)

prompt-coach

On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):

  • a / b / d: a guess goes into the prompt to edit and send (not held again)
  • s: このまま送る sends it unchanged
  • e: 補足して送る puts it back with 完了条件: to fill in (not held again)
  • q: 閉じる puts it back in the prompt

Typing another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.

Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.

OptionDefault
enabledtrue
modelclaude-haiku-5-5Judging model (alias or id)
timeoutMs3000500 to 3000

loopback

Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:

  • self-audit: the violations of the latest audited reply
  • done-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)
  • sandbox-lens: denials seen this session, each once

Each finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.

With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).

  • /loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.
OptionDefault
enabledfalse

Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).

zukan

An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:

  • 📕 新種のエラー発見! No.042「…」 — a kind not seen before
  • 📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it
  • ` 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)

A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.

Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.

  • /zukan opens the book, most recently seen first: kinds, solved, sightings
  • /zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note
  • /zukan forget <No> removes an entry

The newest 300 kinds by last sighting are kept.

pr-lamp

The current branch's pull request, kept in the status line while you work:

⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4

"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.

A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.

  • /pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads
  • /pr <number|url|branch> follows that pull request; /pr clear goes back to the default

Which pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.

It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.

fog-map

The repository as a map under fog, cleared as Claude works through it:

🗺 探索率 31%  ██████░░░░░░░░░░░░░░  読 23 · 編 9 · ✗ 2 / 101
▾ mods/
  ▾ pr-lamp/
    ▾ hooks/
      ● lamp.ts  +43 −2  編集 4回
      ✗ register.tsx  +59 −16  編集 6回  エラー 1  ★
  ▸ loopback/  ○●
  ░ agent-pane/  ░ browser-cam/  ░ context-diet/  …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts

░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.

Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.

  • /map opens the pane
  • The status line keeps 🗺 31% · ●9 · ✗2 · ⚠1
  • A toast at 25, 50, 75 and 100% explored, and when an edit first reaches a new area (🗺 新しい区域を開拓: mods/zukan/)

The map is the session's, subagents' calls included; files outside the repository ar

Source 4 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerEntry } from '../types'
5import { classifyCommand, classifyTool, KIND_LABEL, outcomeOf, REVERSIBILITY_LABEL } from './ledger'
6import type { Classified } from './ledger'
7import { maskedOf } from './mask'
8
9const PANE = 'side-effect-ledger'
10const TITLE = '外部操作の台帳'
11const KEEP = 300
12
13const entries = atom({ plugin: 'side-effect-ledger', key: 'entries' } as const, [] as LedgerEntry[])
14
15let queue: Promise<unknown> = Promise.resolve()
16function serial<T>(work: () => Promise<T>): Promise<T> {
17  const run = queue.then(work)
18  queue = run.catch(() => undefined)
19  return run
20}
21
22// The store holds 4,194,304 characters of JSON in all, every repository's ledger together. Before
23// each write the ledgers are held to MAX_LEDGERS and their JSON to BUDGET together, the current
24// one cut to its newest entries past CURRENT_BUDGET, so a write is never refused for room.
25const MAX_LEDGERS = 12
26const BUDGET = 3_500_000
27const CURRENT_BUDGET = 1_500_000
28// What each ledger weighs, kept beside them so a write need not read every other ledger.
29const INDEX_KEY = 'ledger-index'
30
31type Meta = { size: number; at: number; isOpen: boolean }
32const metaOf = (list: readonly LedgerEntry[]): Meta => ({
33  size: JSON.stringify(list).length,
34  at: Math.max(0, ...list.map(one => one.at)),
35  // Open: an undo not yet looked at, of a call that did something (as the status line counts).
36  isOpen: list.some(one => one.isOk && one.undoState === 'unchecked'),
37})
38
39/** The newest entries of a ledger whose JSON fits in `room` characters. */
40function fitting(list: readonly LedgerEntry[], room: number): LedgerEntry[] {
41  const kept: LedgerEntry[] = []
42  let size = 2
43  for (const one of list) {
44    const next = JSON.stringify(one).length + 1
45    if (size + next > room) break
46    size += next
47    kept.push(one)
48  }
49  return kept
50}
51
52/**
53 * Makes room for `next` under `key`: other ledgers leave, those with nothing open first, each time
54 * the one least recently written, until there are fewer than MAX_LEDGERS and all fit in BUDGET.
55 * Answers `next`, cut to its newest entries when it alone would pass CURRENT_BUDGET.
56 */
57async function makeRoom($: EngineInterface, key: string, next: LedgerEntry[]): Promise<{ mine: LedgerEntry[]; index: Record<string, Meta> }> {
58  const stored = (await $.store.get(INDEX_KEY)) as Record<string, Meta> | undefined
59  const index: Record<string, Meta> = typeof stored === 'object' && stored !== null ? { ...stored } : {}
60  const keys = ((await $.store.keys()) as string[]).filter(one => one.startsWith('ledger:') && one !== key)
61  for (const name of Object.keys(index)) if (name === key || !keys.includes(name)) delete index[name]
62  // A ledger the index does not know yet (an earlier version's, another session's): read once.
63  for (const name of keys) if (index[name] === undefined) index[name] = metaOf(((await $.store.get(name)) as LedgerEntry[] | undefined) ?? [])
64  const mine = JSON.stringify(next).length > CURRENT_BUDGET ? fitting(next, CURRENT_BUDGET) : next
65  const order = Object.entries(index).sort(([, a], [, b]) => Number(a.isOpen) - Number(b.isOpen) || a.at - b.at)
66  let total = JSON.stringify(mine).length + order.reduce((sum, [name, one]) => sum + one.size + name.length + 6, 0)
67  let count = order.length
68  for (const [name, one] of order) {
69    if (count < MAX_LEDGERS && total <= BUDGET) break
70    await $.store.delete(name)
71    delete index[name]
72    total -= one.size + name.length + 6
73    count -= 1
74  }
75  return { mine, index: { ...index, [key]: metaOf(mine) } }
76}
77
78/** A store key is held to 256 characters: a longer path keeps its tail, told apart by a hash of the whole. */
79function keyFor(root: string): string {
80  const key = `ledger:${root}`
81  if (key.length <= 256) return key
82  let hash = 5381
83  for (const ch of root) hash = ((hash * 33) ^ ch.charCodeAt(0)) >>> 0
84  return `ledger:${hash.toString(36)}:…${root.slice(-200)}`
85}
86
87// A field as kept: control characters (colour codes) out, as they weigh up to six characters in
88// JSON each, and held to `max`, so one entry stays small whatever a command printed.
89const field = (text: string, max: number): string => {
90  const plain = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
91  return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain
92}
93
94const storeKey = async ($: EngineInterface) => keyFor((await $.session.repo())?.root ?? (await $.session.cwd()))
95
96/**
97 * Changes the ledger in the store and in the session together, the store read first. The store
98 * is shared with other sessions and has no versioned write: it is read back after the write, and
99 * when another session's write has since replaced this one, the change is made again on top of
100 * theirs (`fn` is idempotent), as loopback does.
101 */
102async function write($: EngineInterface, fn: (list: LedgerEntry[]) => LedgerEntry[]): Promise<LedgerEntry[]> {
103  const key = await storeKey($)
104  const get = async () => ((await $.store.get(key)) as LedgerEntry[] | undefined) ?? []
105  return serial(async () => {
106    // The ledger first, then what it weighs: a write cut short leaves an index the next one mends.
107    let room = await makeRoom($, key, fn(await get()).slice(0, KEEP))
108    let next = room.mine
109    // Refused for room, the index under-reported (a ledger another version grew): it is built
110    // again from every ledger, room made by it, and the write tried once more.
111    await $.store.set(key, next).catch(async () => {
112      await $.store.delete(INDEX_KEY)
113      room = await makeRoom($, key, next)
114      next = room.mine
115      await $.store.set(key, next)
116    })
117    const stored = await get()
118    const again = fn(stored).slice(0, KEEP)
119    if (JSON.stringify(again) !== JSON.stringify(stored)) {
120      room = await makeRoom($, key, again)
121      next = room.mine
122      await $.store.set(key, next)
123    }
124    await $.store.set(INDEX_KEY, room.index)
125    await update($, entries, () => next)
126    return next
127  })
128}
129
130const statusOf = (list: readonly LedgerEntry[], sessionId: string): string | undefined => {
131  const mine = list.filter(one => one.sessionId === sessionId && one.isOk)
132  if (mine.length === 0) return undefined
133  const open = mine.filter(one => one.undoState === 'unchecked').length
134  return `📒 ${mine.length}${open > 0 ? ` · 取消未確認 ${open}` : ''}`
135}
136
137async function setState($: EngineInterface, id: string, undoState: LedgerEntry['undoState']): Promise<void> {
138  const list = await write($, all => all.map(one => (one.id === id ? { ...one, undoState } : one)))
139  $.ui.status(statusOf(list, await $.session.id()))
140}
141
142/**
143 * The undo of a call that failed. What it names may be what was there before (a PR that already
144 * existed, a release whose name was in use), so a failed call is given no command to undo it. A
145 * push names only the refs it printed as moved. Infrastructure may have applied a part: it says
146 * so, and to look before undoing anything.
147 */
148function failedUndo(one: Classified, isOk: boolean): string {
149  if (isOk || one.kind === 'push') return one.undo
150  if (one.kind === 'infra') return '失敗したが途中まで反映された可能性あり: 取り消す前に今の状態を確認(helm status / kubectl get / terraform plan など)'
151  return '失敗したため取り消すものはない(途中まで反映されていないかは確認)'
152}
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    await $.command.register({ name: 'ledger', description: 'push・公開・デプロイ・外部 API 更新など Git 差分に出ない操作の台帳(結果と取り消し方法の確認状況)' })
157    const started = await next(e)
158    const list = ((await $.store.get(await storeKey($))) as LedgerEntry[] | undefined) ?? []
159    await update($, entries, () => list)
160    $.ui.status(statusOf(list, await $.session.id()))
161    return started
162  })
163
164  on('tool.call', async ($, e, next) => {
165    const tool = String(e.tool)
166    const input = e as unknown as Record<string, unknown>
167    const ran = await next(e)
168    if (ran.deny !== undefined) return ran
169
170    const text = ran.text ?? ''
171    const found: Classified[] = tool === 'Bash' ? classifyCommand(String(input.command ?? ''), text) : [classifyTool(tool, input)].filter((one): one is Classified => one !== undefined)
172    if (found.length === 0) return ran
173
174    const at = await $.clock.now()
175    const sessionId = await $.session.id()
176    const isOk = ran.isError !== true
177    // Kept across sessions and drawn in the pane: credentials are masked before anything is cut or kept.
178    const source = maskedOf(tool === 'Bash' ? String(input.command ?? '') : `${tool} ${JSON.stringify(input)}`)
179    const fresh: LedgerEntry[] = found.map((one, i) => ({
180      ...one,
181      label: field(maskedOf(one.label), 200),
182      undo: field(maskedOf(failedUndo(one, isOk)), 600),
183      id: `${e.tool_use_id ?? at}:${i}`,
184      at,
185      source: field(source.replace(/\s+/g, ' '), 300),
186      isOk,
187      outcome: field(maskedOf(outcomeOf(text, isOk)), 160),
188      undoState: 'unchecked',
189      sessionId,
190    }))
191    const ids = new Set(fresh.map(one => one.id))
192    const list = await write($, old => [...[...fresh].reverse(), ...old.filter(one => !ids.has(one.id))])
193    $.ui.status(statusOf(list, sessionId))
194    const first = fresh[0] as LedgerEntry
195    $.ui.toast(isOk ? `📒 ${first.label} — 取り消し: ${first.undo.slice(0, 80)}` : `📒 失敗を記録: ${first.label}`)
196    return ran
197  }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
198
199  on('command.run', { command: 'ledger' }, async $ => {
200    await $.ui.open({ id: PANE, title: TITLE })
201    const list = await read($, entries)
202    if (list.length === 0) return { text: '📒 このリポジトリの外部操作はまだ記録されていません。' }
203    const open = list.filter(one => one.isOk && one.undoState === 'unchecked')
204    return {
205      text: [
206        `📒 記録 ${list.length}件(取り消し方法が未確認 ${open.length}件)`,
207        ...list.slice(0, 10).map(one => `- ${new Date(one.at).toLocaleString()} ${one.isOk ? '✓' : '✗'} ${one.label}\n    取り消し(${REVERSIBILITY_LABEL[one.reversibility]}): ${one.undo}`),
208      ].join('\n'),
209    }
210  })
211
212  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213    const { Box, Button, Text } = $.ui.resolve(e)
214    const list = await read($, entries)
215    if (list.length === 0) return <Text dimColor>まだ記録はありません。</Text>
216    const room = Math.max(1, Math.floor(((e.props.scroll.bodyRows ?? 24) - 1) / 4))
217    const mark = { unchecked: '□ 取消未確認', confirmed: '☑ 取消確認済み', waived: '– 確認不要' } as const
218    return (
219      <Box flexDirection="column">
220        {list.slice(0, room).map(one => (
221          <Box key={one.id} flexDirection="column" marginBottom={1}>
222            <Text bold wrap="truncate-end" color={one.isOk ? undefined : 'red'}>
223              {`${one.isOk ? '✓' : '✗'} [${KIND_LABEL[one.kind]}] ${one.label}`}
224            </Text>
225            <Text dimColor wrap="truncate-end">{`  ${new Date(one.at).toLocaleString()} · ${one.outcome}`}</Text>
226            <Text wrap="truncate-end" color={one.reversibility === 'hard' ? 'yellow' : undefined}>{`  ↩ ${REVERSIBILITY_LABEL[one.reversibility]}: ${one.undo}`}</Text>
227            {one.isOk && (
228              <Box>
229                <Text color={one.undoState === 'unchecked' ? 'yellow' : 'green'}>{`  ${mark[one.undoState]} `}</Text>
230                {one.undoState !== 'confirmed' && <Button key={`ok:${one.id}`} plain label="[確認済みにする] " onPress={() => setState($, one.id, 'confirmed')} />}
231                {one.undoState === 'unchecked' && <Button key={`skip:${one.id}`} plain label="[不要] " onPress={() => setState($, one.id, 'waived')} />}
232                {one.undoState !== 'unchecked' && <Button key={`undo:${one.id}`} plain label="[未確認に戻す]" onPress={() => setState($, one.id, 'unchecked')} />}
233              </Box>
234            )}
235          </Box>
236        ))}
237        {list.length > room && <Text dimColor>{`…ほか ${list.length - room} 件`}</Text>}
238      </Box>
239    )
240  })
241}
242
hooks/ledger.ts 389 lines
1// The ledger, pure: which calls reach outside the working tree, and how each would be undone,
2// read from the call and from what it answered.
3
4import type { LedgerEntry, LedgerKind } from '../types'
5
6export type Classified = Pick<LedgerEntry, 'kind' | 'label' | 'undo' | 'reversibility'>
7
8const clip = (text: string, max: number) => {
9  const one = text.replace(/\s+/g, ' ').trim()
10  return one.length > max ? `${one.slice(0, max - 1)}…` : one
11}
12
13const words = (part: string) => (part.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^['"]|['"]$/g, ''))
14
15/** What `git push` printed: the remote, and each ref's old and new commit. */
16export function pushRefs(output: string): { remote?: string; refs: { old?: string; now?: string; ref: string; isNew: boolean; isForced: boolean; isDeleted: boolean }[] } {
17  const remote = output.match(/^To (\S+)/m)?.[1]
18  const refs: { old?: string; now?: string; ref: string; isNew: boolean; isForced: boolean; isDeleted: boolean }[] = []
19  for (const line of output.split('\n')) {
20    const updated = line.match(/^\s*([+ ])\s*([0-9a-f]{7,40})\.{2,3}([0-9a-f]{7,40})\s+\S+\s+->\s+(\S+)/)
21    if (updated !== null) {
22      refs.push({ old: updated[2], now: updated[3], ref: updated[4] as string, isNew: false, isForced: updated[1] === '+', isDeleted: false })
23      continue
24    }
25    const created = line.match(/^\s*\*\s+\[new (?:branch|tag)\]\s+\S+\s+->\s+(\S+)/)
26    if (created !== null) refs.push({ ref: created[1] as string, isNew: true, isForced: false, isDeleted: false })
27    const deleted = line.match(/^\s*-\s+\[deleted\]\s+(\S+)/)
28    if (deleted !== null) refs.push({ ref: deleted[1] as string, isNew: false, isForced: false, isDeleted: true })
29  }
30  return { ...(remote === undefined ? {} : { remote }), refs }
31}
32
33/** What `git push` printed, one block per remote it pushed to (each from its `To <remote>` line). */
34export const pushBlocks = (output: string): string[] =>
35  output
36    .split(/^(?=To \S)/m)
37    .filter(block => block.startsWith('To '))
38    // A fetch after the push (`From …` and its ref lines) is not part of it.
39    .map(block => block.split(/^From \S/m)[0] as string)
40
41// git push options whose value is the next word, so it is not taken for the remote.
42const PUSH_VALUE_OPTIONS = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
43
44function gitPush(argv: string[], output: string, isElsewhere: boolean): Classified {
45  const positional: string[] = []
46  for (let i = 2; i < argv.length; i++) {
47    const word = argv[i] as string
48    if (PUSH_VALUE_OPTIONS.has(word)) i++
49    else if (!word.startsWith('-')) positional.push(word)
50  }
51  const { remote, refs } = pushRefs(output)
52  // With no remote named, git pushed to the branch's upstream: the remote it printed, not necessarily
53  // origin. Run in another directory (cd, -C, a subshell), a remote's name may mean another
54  // remote where the undo is run: the URL git printed means the same one anywhere.
55  const remoteName = isElsewhere ? (remote ?? '<リモートURL>') : (positional[0] ?? remote ?? 'origin')
56  const label = `git ${argv.slice(1).join(' ')}`
57  // No ref printed as moved and the push refused: there is nothing to put back.
58  if (refs.length === 0 && /\[(?:remote )?rejected\]|error: failed to push/.test(output)) {
59    return { kind: 'push', label, undo: 'push は拒否され、何も動いていない', reversibility: 'easy' }
60  }
61  if (refs.length === 0) return { kind: 'push', label, undo: `押し出したコミットを ${remoteName} の以前の位置へ戻す: git push --force-with-lease ${remoteName} <以前のsha>:<ブランチ>(以前の sha は push の出力か git reflog で確認)`, reversibility: 'manual' }
62  const undo = refs
63    .map(one =>
64      one.isDeleted
65        ? `${one.ref} を復元: git push ${remoteName} <削除前のsha>:${one.ref}`
66        : one.isNew
67          ? `git push ${remoteName} --delete ${one.ref}`
68          : `git push --force-with-lease=${one.ref}:${one.now} ${remoteName} ${one.old}:${one.ref}`,
69    )
70    .join(' / ')
71  const forced = refs.some(one => one.isForced)
72  return { kind: 'push', label: `${label}${forced ? '(強制)' : ''}`, undo, reversibility: forced || refs.some(one => one.isDeleted) ? 'hard' : 'easy' }
73}
74
75/**
76 * The first positional word after options, and the options of `where` with their values. An
77 * option in neither list may or may not take the next word: the positional word cannot be told
78 * once one comes before it, and none is named rather than a wrong one.
79 */
80function firstPositional(args: readonly string[], flags: ReadonlySet<string>, values: ReadonlySet<string>, where: RegExp): { first?: string; kept: string[] } {
81  const kept: string[] = []
82  let isUnsure = false
83  let first: string | undefined
84  for (let i = 0; i < args.length; i++) {
85    const word = args[i] as string
86    if (!word.startsWith('-')) {
87      if (first === undefined && !isUnsure) first = word
88      isUnsure ||= first === undefined
89      continue
90    }
91    const name = word.split('=')[0] as string
92    if (where.test(name)) kept.push(...(word.includes('=') ? [word] : [word, args[i + 1] ?? '']))
93    if (word.includes('=') || flags.has(word)) continue
94    if (values.has(word)) i++
95    else if (first === undefined) isUnsure = true
96  }
97  return { ...(first === undefined ? {} : { first }), kept }
98}
99
100const RELEASE_FLAGS = new Set(['-d', '--draft', '-p', '--prerelease', '--generate-notes', '--latest', '--verify-tag', '--notes-from-tag', '--fail-on-no-commits'])
101const RELEASE_VALUES = new Set(['-t', '--title', '-n', '--notes', '-F', '--notes-file', '--target', '-R', '--repo', '--notes-start-tag', '--discussion-category'])
102
103/** The tag of `gh release create` and its -R. */
104function releaseArgs(args: readonly string[]): { tag?: string; repo: string[] } {
105  const { first, kept } = firstPositional(args, RELEASE_FLAGS, RELEASE_VALUES, /^(?:-R|--repo)$/)
106  return { ...(first === undefined ? {} : { tag: first }), repo: kept }
107}
108
109function github(argv: string[], output: string): Classified | undefined {
110  const [, area = '', action = ''] = argv
111  // A numbered PR or issue only: GitHub's hint after a push (`…/pull/new/<branch>`) is not one. The
112  // last, as gh prints what it made last.
113  const url = [...output.matchAll(/https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:pull\/\d+|issues\/\d+|releases\/tag\/[\w.%-]+)/g)].at(-1)?.[0]
114  const number = url?.match(/\/(?:pull|issues)\/(\d+)/)?.[1]
115  const tail = number !== undefined ? ` → #${number}` : url !== undefined ? ` → ${url}` : ''
116  const label = `gh ${area} ${action}${tail}`
117  // A close or delete only on the output's word that this call made the thing: a failure hidden
118  // behind a pipe or `|| true` exits 0 and prints what already existed. By URL, so a PR made with
119  // -R is closed in its repository; the head branch is left alone (it may predate the PR).
120  if (area === 'pr' && action === 'create') {
121    if (/already exists/i.test(output) || url === undefined) return { kind: 'github', label, undo: 'PR が作られたかは gh pr list で確認(既にあった PR は閉じない)', reversibility: 'manual' }
122    return { kind: 'github', label, undo: `gh pr close ${url}`, reversibility: 'easy' }
123  }
124  if (area === 'pr' && action === 'merge') return { kind: 'github', label, undo: 'マージコミットを git revert -m 1 <sha> して push(またはリバート PR を作る)', reversibility: 'manual' }
125  if (area === 'pr' && /^(?:comment|review|edit|close|ready|reopen)$/.test(action)) return { kind: 'github', label, undo: `gh pr ${action === 'close' ? 'reopen' : 'view --web'} ${argv[3] ?? ''}(コメント・レビューは GitHub 上で削除/編集)`.trim(), reversibility: 'manual' }
126  if (area === 'issue' && action === 'create') {
127    // gh prints the new issue's URL last; an earlier one in a chain (gh issue view) is another issue.
128    const made = url?.includes('/issues/') === true ? url : undefined
129    if (made === undefined) return { kind: 'github', label, undo: 'issue が作られたかは gh issue list で確認', reversibility: 'manual' }
130    return { kind: 'github', label, undo: `gh issue close ${made}(削除は gh issue delete)`, reversibility: 'easy' }
131  }
132  if (area === 'issue' && /^(?:comment|edit|close|reopen|delete|transfer)$/.test(action)) return { kind: 'github', label, undo: action === 'delete' ? '削除した issue は戻せません' : 'GitHub 上で編集・再オープン', reversibility: action === 'delete' ? 'hard' : 'manual' }
133  if (area === 'release' && action === 'create') {
134    const { tag, repo } = releaseArgs(argv.slice(3))
135    const target = [tag ?? '<タグ>', ...repo].join(' ')
136    const isMade = tag !== undefined && !/already exists/i.test(output) && url?.endsWith(`/releases/tag/${encodeURIComponent(tag)}`) === true
137    if (!isMade) return { kind: 'publish', label: `gh release create ${tag ?? ''}`.trim(), undo: 'release が作られたかは gh release list で確認(既にあった release は消さない)', reversibility: 'manual' }
138    return {
139      kind: 'publish',
140      label: `gh release create ${tag ?? ''}`.trim(),
141      undo: `gh release delete ${target}(タグも消すなら --cleanup-tag。release より前からあったタグなら付けない)`,
142      reversibility: 'easy',
143    }
144  }
145  if (area === 'repo' && action === 'create') {
146    const made = output.match(/Created repository ([\w.-]+\/[\w.-]+)/)?.[1]
147    if (made === undefined) return { kind: 'github', label, undo: 'リポジトリが作られたかは gh repo view で確認(既にあったリポジトリは消さない)', reversibility: 'manual' }
148    return { kind: 'github', label, undo: `gh repo delete ${made}(delete_repo 権限が必要)`, reversibility: 'manual' }
149  }
150  if (area === 'repo' && action === 'delete') return { kind: 'github', label, undo: '削除したリポジトリは 90 日以内なら Settings から復元を依頼できる場合あり', reversibility: 'hard' }
151  if (area === 'workflow' && action === 'run') return { kind: 'deploy', label, undo: '起動したワークフローは gh run cancel <run-id>。実行済みの副作用はワークフロー次第', reversibility: 'manual' }
152  if (area === 'api' && argv.some((word, i) => /^(?:-X|--method)$/.test(word) && /^(?:POST|PUT|PATCH|DELETE)$/i.test(argv[i + 1] ?? ''))) {
153    return { kind: 'api', label: `gh api ${argv.slice(2).join(' ')}`, undo: '逆の API 呼び出し(作成なら DELETE、更新なら以前の値で PATCH)', reversibility: 'manual' }
154  }
155  return undefined
156}
157
158// git options before the subcommand whose value is the next word (`git -C app push`).
159const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix'])
160
161/**
162 * The git command with the options before its subcommand taken off (`git -C app push` is
163 * `git push`), and those of them that say which repository it ran in.
164 */
165function withoutGitOptions(argv: string[]): { argv: string[]; location: string[] } {
166  if ((argv[0] ?? '').replace(/^.*\//, '') !== 'git') return { argv, location: [] }
167  const location: string[] = []
168  let i = 1
169  while (i < argv.length && (argv[i] as string).startsWith('-')) {
170    const word = argv[i] as string
171    const takesValue = GIT_VALUE_OPTIONS.has(word)
172    if (/^(?:-C|--git-dir|--work-tree)$/.test(word)) location.push(word, argv[i + 1] ?? '')
173    else if (/^(?:--git-dir|--work-tree)=/.test(word)) location.push(word)
174    i += takesValue ? 2 : 1
175  }
176  return { argv: [argv[0] as string, ...argv.slice(i)], location }
177}
178
179/** The words with shell redirections taken off (`2>&1`, `> out.log`, `2>/dev/null`). */
180function withoutRedirects(argv: readonly string[]): string[] {
181  const kept: string[] = []
182  for (let i = 0; i < argv.length; i++) {
183    const word = argv[i] as string
184    if (/^\d*(?:>>?|<)$/.test(word)) i++
185    else if (!/^\d*(?:>>?|<)/.test(word)) kept.push(word)
186  }
187  return kept
188}
189
190const isGitPush = (argv: readonly string[]) => (argv[0] ?? '').replace(/^.*\//, '') === 'git' && argv[1] === 'push'
191
192const HELM_FLAGS = new Set(['-i', '--install', '--wait', '--wait-for-jobs', '--atomic', '--create-namespace', '--force', '--reuse-values', '--reset-values', '--reset-then-reuse-values', '--debug', '--devel', '--cleanup-on-fail', '--keep-history', '--no-hooks', '--skip-crds', '--dependency-update', '--replace', '--insecure-skip-tls-verify', '--take-ownership', '--plain-http', '--hide-notes', '--render-subchart-notes', '--disable-openapi-validation', '--verify', '--pass-credentials', '--enable-dns', '--skip-schema-validation', '-g', '--generate-name'])
193const HELM_VALUES = new Set(['-f', '--values', '--set', '--set-string', '--set-file', '--set-json', '--set-literal', '--version', '-n', '--namespace', '--kube-context', '--kubeconfig', '--timeout', '--repo', '-o', '--output', '--description', '--post-renderer', '--post-renderer-args', '--username', '--password', '--ca-file', '--cert-file', '--key-file', '--history-max', '--keyring', '--registry-config', '--repository-cache', '--repository-config', '--name-template', '--labels', '--max-history', '--cascade'])
194
195/** helm's release and the options that say where it lives. */
196function helmArgs(args: readonly string[]): { release: string; where: string } {
197  const { first, kept } = firstPositional(args, HELM_FLAGS, HELM_VALUES, /^(?:-n|--namespace|--kube-context|--kubeconfig)$/)
198  return { release: first ?? '<release>', where: kept.length === 0 ? '' : ` ${kept.join(' ')}` }
199}
200
201function helm(sub: string, args: readonly string[], output: string): Classified {
202  const { release, where } = helmArgs(args)
203  const label = `helm ${sub} ${args.join(' ')}`.trim()
204  const back = `helm history ${release}${where} → helm rollback ${release} <revision>${where}`
205  // An uninstall only on helm's word that this call installed the release: a name already in use
206  // fails, and behind `|| true` exits 0.
207  const isInstalled = sub === 'install' ? /^STATUS: deployed/m.test(output) : /does not exist\. Installing it now/.test(output)
208  if (sub === 'install') {
209    if (!isInstalled) return { kind: 'infra', label, undo: `helm status ${release}${where} で状態を確認(入っていたリリースを消さないよう uninstall はしない)`, reversibility: 'manual' }
210    return { kind: 'infra', label, undo: `helm uninstall ${release}${where}`, reversibility: 'easy' }
211  }
212  if (sub === 'uninstall' || sub === 'delete') return { kind: 'infra', label, undo: `同じ chart と values で helm install ${release} <chart>${where}(--keep-history 付きで消したなら helm rollback)`, reversibility: 'hard' }
213  if (sub === 'upgrade' && isInstalled) return { kind: 'infra', label, undo: `helm uninstall ${release}${where}`, reversibility: 'easy' }
214  return { kind: 'infra', label, undo: back, reversibility: 'easy' }
215}
216
217const KUBECTL_WHERE = /^(?:--context|-n|--namespace|--kubeconfig|--cluster|-s|--server)$/
218// kubectl's options before the subcommand that take no value; the others take the next word.
219const KUBECTL_FLAGS = new Set(['--insecure-skip-tls-verify', '--match-server-version', '--warnings-as-errors', '--disable-compression'])
220
221/** kubectl with its options before the subcommand read past (`kubectl --context prod apply`); where it acted rides on the undo. */
222function kubectl(args: readonly string[]): Classified | undefined {
223  const where: string[] = []
224  let i = 0
225  while (i < args.length && (args[i] as string).startsWith('-')) {
226    const word = args[i] as string
227    i += word.includes('=') || KUBECTL_FLAGS.has(word) || /^-v\d+$/.test(word) ? 1 : 2
228  }
229  const [sub = '', third = ''] = args.slice(i)
230  for (let j = 0; j < args.length; j++) {
231    const word = args[j] as string
232    if (KUBECTL_WHERE.test(word.split('=')[0] as string)) where.push(...(word.includes('=') ? [word] : [word, args[j + 1] ?? '']))
233  }
234  if (!(/^(?:apply|create|delete|replace|patch|scale|set|edit|label|annotate)$/.test(sub) || (sub === 'rollout' && /^(?:undo|restart|pause|resume)$/.test(third)))) return undefined
235  const undo = sub === 'delete' ? '削除したリソースは以前のマニフェストで kubectl apply し直す' : 'kubectl rollout undo <deployment>、または以前のマニフェストを kubectl apply'
236  return { kind: 'infra', label: `kubectl ${args.join(' ')}`, undo: withWhere(undo, where), reversibility: sub === 'delete' ? 'hard' : 'manual' }
237}
238
239/** Whether the command only shows what it would do. */
240const isDryRun = (argv: readonly string[]): boolean =>
241  argv.some(word => word === '--dry-run' || (word.startsWith('--dry-run=') && word !== '--dry-run=none')) ||
242  (isGitPush(argv) && argv.slice(2).some(word => /^-[A-Za-z]*n[A-Za-z]*$/.test(word)))
243
244/** What one shell command (no pipes or chains) reaches outside, or undefined. */
245export function classifyArgv(given: string[], output: string, isElsewhere = false): Classified | undefined {
246  const { argv, location } = withoutGitOptions(given)
247  if (isDryRun(argv)) return undefined
248  const [program = '', sub = '', third = ''] = argv
249  const name = program.replace(/^.*\//, '')
250  const rest = argv.slice(1).join(' ')
251  if (name === 'git' && sub === 'push') return gitPush(argv, output, isElsewhere || location.length > 0)
252  if (name === 'gh') return github(argv, output)
253  if (/^(?:npm|pnpm|yarn|bun)$/.test(name) && sub === 'publish') {
254    const version = output.match(/\+\s*([@\w./-]+@\d+\.\d+\.\d+[\w.-]*)/)?.[1]
255    return { kind: 'publish', label: `${name} publish${version ? ` → ${version}` : ''}`, undo: `npm unpublish ${version ?? '<pkg>@<version>'}(72時間以内・依存がない場合のみ。以後は npm deprecate)`, reversibility: 'hard' }
256  }
257  if (name === 'cargo' && sub === 'publish') return { kind: 'publish', label: 'cargo publish', undo: 'cargo yank --version <version>(削除はできず、新規依存を止めるだけ)', reversibility: 'hard' }
258  if ((name === 'twine' && sub === 'upload') || (name === 'poetry' && sub === 'publish') || (name === 'uv' && sub === 'publish') || (name === 'flit' && sub === 'publish')) {
259    return { kind: 'publish', label: `${name} ${sub}`, undo: 'PyPI の管理画面で yank(同じバージョン番号は再利用不可)', reversibility: 'hard' }
260  }
261  if (name === 'gem' && sub === 'push') return { kind: 'publish', label: 'gem push', undo: 'gem yank <gem> -v <version>', reversibility: 'hard' }
262  if (name === 'docker' && (sub === 'push' || (sub === 'buildx' && /--push\b/.test(rest)) || (sub === 'image' && third === 'push'))) {
263    return { kind: 'publish', label: `docker ${rest}`, undo: 'レジストリ側でタグを削除し、以前のダイジェストを付け直す', reversibility: 'manual' }
264  }
265  if (name === 'kubectl') return kubectl(argv.slice(1))
266  if (name === 'helm' && /^(?:install|upgrade|uninstall|rollback|delete)$/.test(sub)) return helm(sub, argv.slice(2), output)
267  if (name === 'terraform' && (/^(?:apply|destroy|import)$/.test(sub) || (sub === 'state' && /^(?:rm|mv|push|replace-provider)$/.test(third)))) return { kind: 'infra', label: `terraform ${rest}`, undo: '以前の設定に戻して terraform apply(state のバックアップは terraform.tfstate.backup)', reversibility: sub === 'destroy' ? 'hard' : 'manual' }
268  if ((name === 'pulumi' && /^(?:up|destroy)$/.test(sub)) || (name === 'cdk' && /^(?:deploy|destroy)$/.test(sub))) return { kind: 'infra', label: `${name} ${rest}`, undo: '以前のコードに戻して再デプロイ(pulumi stack history / cdk diff で差を確認)', reversibility: 'manual' }
269  if (name === 'vercel' && (sub === '' || sub === 'deploy' || (sub.startsWith('-') && !/^(?:-v|--version|-h|--help)$/.test(sub)))) {
270    // A production deploy is rolled back, never removed; only a preview is removed, by the URL
271    // vercel printed as the preview's.
272    if (/--prod\b|--target(?:=|\s+)production\b/.test(rest)) return { kind: 'deploy', label: `vercel ${rest}`.trim(), undo: 'vercel rollback(または以前のデプロイを vercel promote)', reversibility: 'easy' }
273    if (/--target(?:=|\s+)(?!preview\b)\S/.test(rest)) return { kind: 'deploy', label: `vercel ${rest}`.trim(), undo: 'Vercel ダッシュボードでその環境の以前のデプロイを promote(デプロイは削除しない)', reversibility: 'manual' }
274    const preview = output.match(/Preview:\s*(https:\/\/[\w.-]+\.vercel\.app)\b/)?.[1]
275    return { kind: 'deploy', label: `vercel ${rest}`.trim(), undo: `vercel remove ${preview ?? '<プレビューURL>'}(プレビューのみ、本番は変わっていない)`, reversibility: 'easy' }
276  }
277  if (name === 'netlify' && sub === 'deploy') return { kind: 'deploy', label: `netlify ${rest}`, undo: 'Netlify の Deploys 画面で以前のデプロイを Publish', reversibility: 'easy' }
278  if ((name === 'fly' || name === 'flyctl') && sub === 'deploy') return { kind: 'deploy', label: `${name} deploy`, undo: 'fly releases で以前のイメージを確認し fly deploy --image <以前のイメージ>', reversibility: 'manual' }
279  if (name === 'wrangler' && /^(?:deploy|publish)$/.test(sub)) return { kind: 'deploy', label: `wrangler ${rest}`, undo: 'wrangler rollback', reversibility: 'easy' }
280  if (name === 'wrangler' && sub === 'pages' && /^(?:deploy|publish)$/.test(third)) return { kind: 'deploy', label: `wrangler ${rest}`, undo: 'Cloudflare ダッシュボードの Pages で以前のデプロイへロールバック', reversibility: 'easy' }
281  if (name === 'firebase' && sub === 'deploy') return { kind: 'deploy', label: `firebase ${rest}`, undo: 'Hosting はコンソールで以前のリリースへロールバック、Functions は以前のコードで再デプロイ', reversibility: 'manual' }
282  if (name === 'heroku' && /^(?:releases:rollback|rollback|config:set|ps:scale)$/.test(sub)) return { kind: 'deploy', label: `heroku ${rest}`, undo: 'heroku releases → heroku rollback v<番号>', reversibility: 'easy' }
283  if (name === 'gcloud' && /\b(?:deploy|delete|create|update)\b/.test(rest)) return { kind: 'deploy', label: `gcloud ${rest}`, undo: 'Cloud Run は gcloud run services update-traffic --to-revisions=<以前のrevision>=100、その他は以前の設定で再実行', reversibility: 'manual' }
284  if (name === 'aws' && (/^s3$/.test(sub) ? /^(?:cp|mv|rm|sync)$/.test(third) : /\b(?:deploy|create|update|delete|put|run-instances|terminate)/.test(third))) {
285    return { kind: 'infra', label: `aws ${sub} ${third}`, undo: sub === 's3' ? 'バケットのバージョニングがあれば以前のバージョンへ戻す(なければ元ファイルから再アップロード)' : '以前の設定で同じ API を呼び直す', reversibility: third === 'rm' || third === 'delete' || third === 'terminate-instances' ? 'hard' : 'manual' }
286  }
287  if (/^(?:curl|http|https|xh|wget)$/.test(name)) {
288    const method = argv.find((word, i) => /^(?:-X|--request)$/.test(argv[i - 1] ?? '') && /^(?:POST|PUT|PATCH|DELETE)$/i.test(word)) ?? (argv.some(word => /^(?:-d|--data|--data-raw|--data-binary|--json|-F|--form)$/.test(word) || /^--data/.test(word)) ? 'POST' : undefined)
289    const httpieMethod = /^(?:http|https|xh)$/.test(name) && /^(?:POST|PUT|PATCH|DELETE)$/i.test(sub) ? sub.toUpperCase() : undefined
290    const verb = (method ?? httpieMethod)?.toUpperCase()
291    if (verb === undefined) return undefined
292    const url = argv.find(word => /^https?:\/\//.test(word)) ?? ''
293    if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/.test(url)) return undefined
294    return { kind: 'api', label: `${verb} ${clip(url, 80)}`, undo: verb === 'DELETE' ? '削除したリソースは API 側で復元できるか確認' : '逆の API 呼び出し(作成なら DELETE、更新なら以前の値で PUT/PATCH)', reversibility: verb === 'DELETE' ? 'hard' : 'manual' }
295  }
296  if ((name === 'scp' || name === 'rsync') && argv.slice(1).some(word => /^[\w.-]+@?[\w.-]*:[^/]/.test(word) || /^[\w.-]+@[\w.-]+:/.test(word))) {
297    return { kind: 'remote', label: `${name} ${clip(rest, 80)}`, undo: 'リモートの上書き前のファイルはバックアップから戻す(rsync --delete で消えたものは戻らない)', reversibility: /--delete\b/.test(rest) ? 'hard' : 'manual' }
298  }
299  return undefined
300}
301
302// Environment that says which cluster, namespace or repository a command acts on.
303// A token is not among them: masked, it could not be replayed anyway.
304const WHERE_ENV = /^(?:KUBECONFIG|HELM_(?:NAMESPACE|KUBECONTEXT|KUBEAPISERVER|DRIVER)|GH_REPO|GH_HOST)=/
305
306const WHERE_NOTE = /(どれも (.+) を付けて実行)$/
307
308/** The undo with where its command acted named for every command in it (an undo may name several); one note, however many sources. */
309export function withWhere(undo: string, where: readonly string[]): string {
310  if (where.length === 0) return undo
311  const noted = undo.match(WHERE_NOTE)
312  const all = noted === null ? where : [...where, noted[1] as string]
313  return `${noted === null ? undo : undo.slice(0, -noted[0].length)}(どれも ${all.join(' ')} を付けて実行)`
314}
315
316/**
317 * Every outside-reaching part of a Bash command; env prefixes, redirections and subshell
318 * parentheses taken off. A push is given a sha to force back only when it is the command's one
319 * push and git printed one `To` block: with two pushes, or a remote with two push URLs, a block
320 * could be another remote's. A command that may run git elsewhere (cd, pushd, a subshell,
321 * GIT_DIR) names the remote by the URL git printed.
322 */
323export function classifyCommand(command: string, output: string): Classified[] {
324  let isElsewhere = false
325  const parts = command.split(/&&|\|\||;|\n|\|/).map(part => {
326    if (/^\s*\(/.test(part)) isElsewhere = true
327    const argv = withoutRedirects(words(part.replace(/^\s*\(+/, '').replace(/\)+\s*$/, '')))
328    const where: string[] = []
329    while (argv.length > 0 && (/^\w+=/.test(argv[0] as string) || argv[0] === 'sudo' || argv[0] === 'env' || argv[0] === 'command')) {
330      const word = argv.shift() as string
331      if (/^GIT_(?:DIR|WORK_TREE)=/.test(word)) isElsewhere = true
332      if (WHERE_ENV.test(word)) where.push(word)
333    }
334    if (/^(?:cd|pushd|popd)$/.test(argv[0] ?? '')) isElsewhere = true
335    return { argv, where }
336  })
337  const pushes = parts.filter(({ argv }) => isGitPush(withoutGitOptions(argv).argv)).length
338  // gh and helm print the evidence a close, delete or uninstall is given on (a PR URL, `STATUS:
339  // deployed`); with two of them in one command, whose it is cannot be told, so neither sees it.
340  // git prints none of it, so `git push && gh pr create` keeps it.
341  const isShared = parts.filter(({ argv }) => /^(?:gh|helm)$/.test((argv[0] ?? '').replace(/^.*\//, ''))).length > 1
342  const blocks = pushBlocks(output)
343  const pushOutput = pushes === 1 && blocks.length === 1 ? (blocks[0] as string) : ''
344  const found: Classified[] = []
345  for (const { argv, where } of parts) {
346    if (/^(?:cd|pushd|popd)$/.test(argv[0] ?? '')) continue
347    const program = (argv[0] ?? '').replace(/^.*\//, '')
348    const seen = isGitPush(withoutGitOptions(argv).argv) ? pushOutput : isShared && /^(?:gh|helm)$/.test(program) ? '' : output
349    const one = classifyArgv(argv, seen, isElsewhere)
350    if (one === undefined) continue
351    // The undo of a command its environment pointed elsewhere (KUBECONFIG, GH_REPO) goes there too.
352    found.push(where.length > 0 && /^(?:helm|gh|kubectl)$/.test(program) ? { ...one, undo: withWhere(one.undo, where) } : one)
353  }
354  return found
355}
356
357const MCP_WRITE = /(?:^|_)(?:create|update|delete|remove|send|post|publish|upload|merge|close|comment|reply|archive|move|rename|share|invite|add|set|write|edit|insert|append|schedule|cancel|deploy|trigger)(?:_|$)/i
358
359/** A tool other than Bash that reaches outside: an MCP write, an artifact publish. */
360export function classifyTool(tool: string, input: Record<string, unknown>): Classified | undefined {
361  if (tool === 'Artifact') {
362    const action = typeof input.action === 'string' ? input.action : 'publish'
363    if (action === 'publish' && input.asset !== true) return { kind: 'publish', label: `Artifact publish${typeof input.url === 'string' ? `(更新 ${input.url})` : ''}`, undo: 'Artifact を action: "delete" で削除(共有済みの閲覧は止まる)', reversibility: 'easy' }
364    if (action === 'delete') return { kind: 'publish', label: 'Artifact delete', undo: '削除した Artifact は戻せません', reversibility: 'hard' }
365    return undefined
366  }
367  const m = tool.match(/^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/)
368  if (m === null) return undefined
369  const [, server = '', action = ''] = m
370  if (/claude-in-chrome|Claude_Browser|plugin-authoring/.test(server) || !MCP_WRITE.test(action)) return undefined
371  const isMessage = /send|post|reply|comment|invite|share/i.test(action)
372  return {
373    kind: isMessage ? 'message' : 'api',
374    label: `${server.replace(/^claude_ai_/, '')}: ${action}`,
375    undo: isMessage ? '送信したメッセージは相手側に残る(削除・訂正はサービス上で)' : `${server.replace(/^claude_ai_/, '')} 上で元に戻す(対応する delete/update を呼ぶ)`,
376    reversibility: isMessage ? 'hard' : 'manual',
377  }
378}
379
380/** A line of what the call answered, for the ledger. */
381export function outcomeOf(text: string, isOk: boolean): string {
382  const lines = text.split('\n').map(line => line.trim()).filter(Boolean)
383  const pick = isOk ? (lines.find(line => /https?:\/\/|->|→|published|deployed|created|✓|success/i.test(line)) ?? lines.at(-1)) : (lines.find(line => /error|fatal|denied|rejected|failed/i.test(line)) ?? lines.at(-1))
384  return clip(pick ?? (isOk ? '成功' : '失敗'), 160)
385}
386
387export const KIND_LABEL: Record<LedgerKind, string> = { push: 'push', publish: '公開', deploy: 'デプロイ', infra: 'インフラ', github: 'GitHub', api: 'API', remote: 'リモート', message: '送信' }
388export const REVERSIBILITY_LABEL: Record<LedgerEntry['reversibility'], string> = { easy: '戻しやすい', manual: '手作業で戻す', hard: '戻すのが難しい' }
389
hooks/mask.ts 101 lines
1// Masking credentials in a command before it is kept, as sandbox-lens does (copied from its
2// parse.ts: mods share no code).
3
4const EXAMPLE_CHARS = 120
5
6// Name segments that mark a value as a credential (`ANTHROPIC_API_KEY`, `x-access-token`, `db_pass`).
7const SECRET_WORDS = new Set([
8  'key', 'apikey', 'token', 'secret', 'password', 'passwd', 'pass', 'pwd',
9  'auth', 'cookie', 'session', 'credential', 'credentials',
10])
11// Words that mark a credential even inside a camelCase name (`accessToken`, `clientSecret`, `APIKey`),
12// and endings that mark one run together (`PGPASSWORD`). `key` and `session` are left out here:
13// `primaryKey` and `sessionId` are ids, not credentials.
14const STRONG_WORDS = new Set(['apikey', 'token', 'secret', 'password', 'passwd', 'credential', 'credentials'])
15const SECRET_ENDINGS = ['password', 'passwd', 'token', 'secret', 'apikey']
16const isStrongName = (name: string) =>
17  name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[_-]/).some(part => STRONG_WORDS.has(part)) ||
18  SECRET_ENDINGS.some(end => name.toLowerCase().replace(/[_-]/g, '').endsWith(end))
19const isSecretName = (name: string) => name.toLowerCase().split(/[_-]/).some(part => SECRET_WORDS.has(part)) || isStrongName(name)
20
21// A value: quoted whole, or up to whitespace or a shell separator.
22const VALUE = String.raw`(?:"[^"]*"|'[^']*'|[^\s&;|'"]+)`
23
24type Replacer = (substring: string, ...groups: string[]) => string
25type Mask = [RegExp, string | Replacer]
26
27// NAME=value. The name is bounded so a long run such as `a-a-a-…` is scanned in linear time, not once per hyphen.
28const NAME_VALUE = new RegExp(String.raw`\b([A-Za-z][\w-]{0,127})=(${VALUE})`, 'g')
29const maskName: Replacer = (whole, name = '') => (isSecretName(name) ? `${name}=***` : whole)
30// One level into the value, not recursively: `a=a=a=…` must not grow the stack.
31const maskNameValue: Replacer = (whole, name = '', value = '') =>
32  isSecretName(name) ? `${name}=***` : value.includes('=') ? `${name}=${value.replace(NAME_VALUE, maskName)}` : whole
33
34// Credentials a command line may carry, each kept to its name.
35const SECRETS: Mask[] = [
36  // URL userinfo: user:password@ keeps the user, a token alone@ goes whole; host and path stay.
37  // The scheme is bounded, as the NAME=value name below is, to keep a long hyphenated run linear.
38  [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"]+):([^/\s@'"]+)@/gi, '$1$2:***@'],
39  [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"*]+)@/gi, '$1***@'],
40  // Headers: Authorization keeps its scheme, Cookie loses every pair.
41  [/(authorization:\s*)(?:(bearer|basic|token)\s+)?[^\s'"]+/gi, (_, head = '', scheme = '') => `${head}${scheme ? `${scheme} ` : ''}***`],
42  [/(\bcookie:\s*)[^\s'";]+(?:;\s*[^\s'";]+)*;?/gi, '$1***'],
43  [/\b(bearer)\s+(?!\*\*\*)[\w.~+/=-]+/gi, '$1 ***'],
44  // Any other header a request sends whose name reads as a credential (-H "X-API-Key: x"); only
45  // as -H/--header, so an error line such as `Unexpected token: '<'` is left as it is. A quoted
46  // value goes whole up to its closing quote (`X-Auth: Token abc`); an unquoted header is one shell
47  // word with no space after the colon, so `grep -H password: config.yml` keeps its file name.
48  [
49    /((?:^|\s)(?:-H|--header)(?:=|\s*))(?:(["'])([\w-]+)(:\s*)(?:(?!\2)[^\n])+|([\w-]+)(:)[^\s'"]+)/g,
50    (whole, head = '', quote = '', quotedName = '', quotedColon = '', name = '', colon = '') => {
51      if (quote !== '') return isSecretName(quotedName) ? `${head}${quote}${quotedName}${quotedColon}***` : whole
52      return isSecretName(name) ? `${head}${name}${colon}***` : whole
53    },
54  ],
55  // -u user:pass, -uuser:pass, --user=user:pass, -u "user:pass"
56  [/(\s(?:-u|--user)(?:=|\s+)?)(["']?)([^\s:'"]+):([^\s'"]+)/g, '$1$2$3:***'],
57  // --password x, --token=x, --client-secret "x"
58  [
59    new RegExp(String.raw`(--(?:client-secret|api-?key|secret|token|auth-token|access-token|password|passwd)(?:=|\s+))${VALUE}`, 'gi'),
60    '$1***',
61  ],
62  // NAME=value where the name reads as a credential, quoted values too (env assignments, query strings).
63  // A value that is itself NAME=value (kubectl --from-literal=password=x) is looked into.
64  [NAME_VALUE, maskNameValue],
65  // "name": "value" (JSON in an output line) where the name reads as a credential.
66  // A bare "key" or "session" is an id there (a path, an S3 key), not a credential.
67  [/("([\w-]+)"\s*:\s*)"[^"]*"/g, (whole, head = '', name = '') => (isSecretName(name) && !/^(?:key|session)$/i.test(name) ? `${head}"***"` : whole)],
68  // Bare tokens with a known shape.
69  [/\b(sk-(?:ant-)?)[\w-]{16,}/g, '$1***'],
70  [/\b(gh[pousr]_)[A-Za-z0-9]{10,}/g, '$1***'],
71  [/\b(github_pat_)\w{10,}/g, '$1***'],
72  [/\b(xox[abprs]-)[\w-]{8,}/g, '$1***'],
73  [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
74  [/\b(glpat-)[\w-]{20,}/g, '$1***'],
75  [/\b(npm_)[A-Za-z0-9]{36}\b/g, '$1***'],
76  [/\b((?:sk|rk)_(?:live|test)_)[A-Za-z0-9]{10,}/g, '$1***'],
77  [/\bAIza[\w-]{35}\b/g, 'AIza***'],
78  // Started only where no word or hyphen comes before, so `eyJ-eyJ-…` is not rescanned from each one.
79  [/(?<![\w-])eyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]*/g, 'eyJ***'],
80]
81
82// Programs whose -p takes a password: attached (mysql -pX) or as the next word (sshpass -p X, docker login -p X).
83const ATTACHED_P = /\b(?:mysql|mariadb|mysqldump|mysqladmin)\b/
84// The words before `login` are bounded, so a long run of `docker docker …` stays linear.
85const SPACED_P = /\bsshpass\b|\b(?:docker|podman|nerdctl|helm)\b(?:\s+\S+){0,8}?\s+login\b/
86
87/** The text with the credentials it carries masked: a command, or a line of its output. */
88export function maskedOf(text: string): string {
89  let masked = SECRETS.reduce((all, [pattern, mask]) => all.replace(pattern, mask as Replacer), text)
90  if (ATTACHED_P.test(masked)) masked = masked.replace(/(\s-p)(?!\s)\S+/g, '$1***')
91  if (SPACED_P.test(masked)) masked = masked.replace(/(\s-p\s+)\S+/g, '$1***')
92  return masked
93}
94
95/** The command as an example: credentials masked, cut to a line. */
96export function exampleOf(command: string): string {
97  const masked = maskedOf(command).replace(/ {2,}/g, ' ')
98  return masked.length > EXAMPLE_CHARS ? `${masked.slice(0, EXAMPLE_CHARS)}…` : masked
99}
100
101
types/index.d.ts 31 lines
1export type LedgerKind = 'push' | 'publish' | 'deploy' | 'infra' | 'github' | 'api' | 'remote' | 'message'
2
3/** One operation that changed something outside the working tree. */
4export type LedgerEntry = {
5  id: string
6  at: number
7  kind: LedgerKind
8  /** What it did, short (`git push origin main`, `gh pr create → #42`). */
9  label: string
10  /** The command or tool call as made, clipped. */
11  source: string
12  /** Whether it succeeded, and a line of what it answered. */
13  isOk: boolean
14  outcome: string
15  /** How it would be undone, as specific as its output allowed; and how hard that is. */
16  undo: string
17  reversibility: 'easy' | 'manual' | 'hard'
18  /** unchecked: nobody has looked at the undo; confirmed: the person checked it; waived: not needed. */
19  undoState: 'unchecked' | 'confirmed' | 'waived'
20  sessionId: string
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'side-effect-ledger': {
26      /** The repository's ledger, newest first, as the store keeps it. */
27      entries: LedgerEntry[]
28    }
29  }
30}
31