Asks before a git commit or git add takes in large files, data files, personal-looking table columns or Jupyter notebooks with outputs. Only names are ever…

Asks once before a git commit or git add takes in the wrong things for a data project. No model calls, no cost.
When a Bash command runs git commit or git add (also inside && chains), data-guard looks at what would be committed: the staged files (git diff --cached), the modified tracked files for commit -a, and for git add the files it names, or -A, ., -u (git status). It flags:
maxFileMb (default 5 MB)..gitignore ignores them or .gitattributes sends them to git lfs.Nothing found: the command passes silently. Otherwise one dialog: Hold it (default; Esc also holds), Commit anyway, Fix it (holds and tells the model what to do), and, when notebooks are involved, Strip outputs. Strip outputs rewrites the working copy of those notebooks without outputs and execution counts (indent 1, trailing newline, as nbformat does), runs git add on them, and lets the command continue. If other findings remain it still holds the command. Note that git add stages the whole working file, including unstaged edits to it.
/data-guard [on|off|status] shows the settings and this session's counts (checked, asked, held, notebooks stripped). on/off is remembered, and mirrored in the state atom data-guard.isOff (boolean, default false) so the moddeck side bar can show it.
| Setting | Default | |
|---|---|---|
enabled | true | Master switch. When false, /data-guard on cannot override it. |
maxFileMb | 5 | Large file limit in MB. |
checkNotebooks | true | Check .ipynb outputs. |
checkColumns | true | Check csv/tsv header names. |
rev-parse, diff, status, check-attr, check-ignore, cat-file, show), with short timeouts, plus git add on stripped notebooks.-p run) holds.Limits: it reads the command text, so git -C other-dir commit is checked against the session folder, and anything built by a script or alias is not seen.
hooks/register.tsx 291 lines1// data-guard: asks before a `git commit` or `git add` takes in the wrong things for a data project.
2//
3// WHAT IT LOOKS AT (git only, read-only, short timeouts): the files that would be committed, from
4// `git diff --cached`, and for `git add` the files it names, from `git status`. It flags large files, data files that are
5// neither ignored nor in git lfs, csv/tsv first lines with personal-looking column NAMES, and notebooks with outputs.
6//
7// NEVER LOGS OR STORES CONTENTS. Paths, counts and column names are all that is ever shown. File text is read only to
8// look at a first line or the JSON shape of a notebook and is dropped at once.
9//
10// FAIL OPEN: any error or timeout (a git that is missing, a process that throws, a hook outrunning its budget) lets the
11// command through untouched. The only refusal is the person's own answer: Hold it, Fix it, or Esc.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { DataGuardTally } from '../types'
16import {
17 fixText,
18 firstLineOf,
19 hasAny,
20 isDataLike,
21 isNotebook,
22 isTable,
23 notebookHasOutputs,
24 parseNameStatus,
25 parsePorcelain,
26 personalColumns,
27 planOf,
28 questionText,
29 addNames,
30 stripNotebook,
31} from './detect'
32import type { Findings, GitPlan } from './detect'
33
34type Dollar = EngineInterface
35type Cfg = { isEnabled: boolean; maxMb: number; checkNotebooks: boolean; checkColumns: boolean }
36
37const COMMAND = 'data-guard'
38const MB = 1024 * 1024
39const READ_LIMIT = 4 * MB
40const GIT_TIMEOUT_MS = 8000
41const MAX_CANDIDATES = 3000
42const MAX_STATS = 500
43const MAX_READS = 30
44
45const COMMIT = 'Commit anyway'
46const FIX = 'Fix it'
47const HOLD = 'Hold it'
48const STRIP = 'Strip outputs'
49
50const isOffAtom = atom({ plugin: 'data-guard', key: 'isOff' } as const, false)
51const EMPTY: DataGuardTally = { checked: 0, asked: 0, held: 0, stripped: 0 }
52const tallyAtom = atom({ plugin: 'data-guard', key: 'tally' } as const, EMPTY)
53
54const num = (v: unknown, d: number, lo: number, hi: number): number => (typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : d)
55
56const readConfig = (o: Record<string, unknown>): Cfg => ({
57 isEnabled: o.enabled !== false,
58 maxMb: num(o.maxFileMb, 5, 0.1, 1000),
59 checkNotebooks: o.checkNotebooks !== false,
60 checkColumns: o.checkColumns !== false,
61})
62
63async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
64 try {
65 return await f()
66 } catch {
67 return undefined
68 }
69}
70
71async function isOff($: Dollar): Promise<boolean> {
72 return (await safe(() => $.store.get('off'))) === true
73}
74
75async function setSwitch($: Dollar, off: boolean): Promise<void> {
76 if (off) await $.store.set('off', true)
77 else await $.store.delete('off')
78 await update($, isOffAtom, () => off)
79}
80
81const bump = (k: keyof DataGuardTally, by = 1) => (t: DataGuardTally): DataGuardTally => ({ ...t, [k]: t[k] + by })
82const count = ($: Dollar, k: keyof DataGuardTally, by = 1) => safe(() => update($, tallyAtom, bump(k, by)))
83
84// ---- looking ------------------------------------------------------------------------------------------------------
85
86type Source = 'index' | 'tree'
87type Seen = { findings: Findings; notebookPaths: string[]; top: string }
88
89/** Read-only look at what the plan would put in a commit. Throws on a broken engine call (the caller fails open). */
90async function inspect($: Dollar, plan: GitPlan, cfg: Cfg): Promise<Seen | undefined> {
91 const git = async (argv: string[], top: string | undefined, stdin?: string) => {
92 const r = await $.process.run(['git', ...argv], { ...(top === undefined ? {} : { cwd: top }), timeoutMs: GIT_TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
93 return r.exitCode === 0 && !r.isStdoutTruncated ? r.stdout : undefined
94 }
95
96 const where = await git(['rev-parse', '--show-toplevel', '--show-prefix'], undefined)
97 if (where === undefined) return undefined // not a git folder
98 const [topLine = '', prefixLine = ''] = where.split('\n')
99 const top = topLine.trim()
100 const prefix = prefixLine.trim().replace(/\/$/, '')
101 if (top === '') return undefined
102
103 const cand = new Map<string, Source>()
104 if (plan.commit) {
105 for (const f of parseNameStatus((await git(['diff', '--cached', '--name-status', '-z'], top)) ?? '')) cand.set(f.path, 'index')
106 if (plan.commit.isAll) for (const f of parseNameStatus((await git(['diff', '--name-status', '-z'], top)) ?? '')) cand.set(f.path, 'tree')
107 }
108 if (plan.adds.length) {
109 const status = parsePorcelain((await git(['status', '--porcelain', '-z', '--untracked-files=all'], top)) ?? '')
110 for (const s of status) {
111 const isNew = s.x === '?'
112 if (!isNew && s.y === ' ') continue // already staged and unchanged since
113 for (const a of plan.adds) {
114 const wanted = a.paths.length === 0 ? a.isAll || a.isTrackedOnly : a.paths.some(p => addNames(p, prefix, s.path))
115 if (wanted && !(a.isTrackedOnly && !a.isAll && isNew)) {
116 cand.set(s.path, 'tree')
117 break
118 }
119 }
120 }
121 }
122 const paths = [...cand.keys()].filter(p => !p.includes('\n')).slice(0, MAX_CANDIDATES)
123 const findings: Findings = { large: [], data: [], columns: [], notebooks: [] }
124 if (paths.length === 0) return { findings, notebookPaths: [], top }
125
126 // git lfs and .gitignore coverage, best effort: a failure here only means "not covered".
127 const lfs = new Set<string>()
128 const attr = await safe(() => git(['check-attr', 'filter', '--stdin', '-z'], top, paths.join('\0')))
129 if (attr) {
130 const f = attr.split('\0')
131 for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] === 'lfs') lfs.add(f[i]!)
132 }
133 const ignored = new Set<string>()
134 const ign = await safe(() => $.process.run(['git', 'check-ignore', '--stdin', '-z'], { cwd: top, timeoutMs: GIT_TIMEOUT_MS, stdin: paths.join('\0') }))
135 if (ign && ign.exitCode === 0) for (const p of ign.stdout.split('\0')) if (p) ignored.add(p)
136
137 // sizes: the index' blob for staged files, the working file for the rest
138 const size = new Map<string, number>()
139 const inIndex = paths.filter(p => cand.get(p) === 'index').slice(0, MAX_STATS)
140 if (inIndex.length) {
141 const out = await git(['cat-file', '--batch-check'], top, inIndex.map(p => `:${p}`).join('\n') + '\n')
142 const lines = (out ?? '').split('\n')
143 inIndex.forEach((p, i) => {
144 const m = /^[0-9a-f]+ blob (\d+)$/.exec(lines[i] ?? '')
145 if (m) size.set(p, Number(m[1]))
146 })
147 }
148 for (const p of paths.filter(p => cand.get(p) === 'tree').slice(0, MAX_STATS)) {
149 const st = await safe(() => $.fs.stat(`${top}/${p}`))
150 if (st && st.kind === 'file') size.set(p, st.size)
151 }
152
153 const limit = cfg.maxMb * MB
154 for (const p of paths) {
155 const bytes = size.get(p)
156 const isLfs = lfs.has(p)
157 if (bytes !== undefined && bytes > limit && !isLfs) findings.large.push({ path: p, bytes })
158 if (isDataLike(p) && !isLfs && !ignored.has(p)) findings.data.push(p)
159 }
160
161 // contents: only ever a first line or the shape of a notebook; nothing is kept
162 const body = async (p: string): Promise<string | undefined> => {
163 const bytes = size.get(p)
164 if (bytes === undefined || bytes > READ_LIMIT) return undefined
165 return cand.get(p) === 'index' ? git(['show', `:${p}`], top) : safe(() => $.fs.read(`${top}/${p}`))
166 }
167 const notebookPaths: string[] = []
168 if (cfg.checkColumns) {
169 for (const p of paths.filter(p => isTable(p) && !lfs.has(p)).slice(0, MAX_READS)) {
170 const text = await body(p)
171 const names = text === undefined ? [] : personalColumns(firstLineOf(text))
172 if (names.length) findings.columns.push({ path: p, names })
173 }
174 }
175 if (cfg.checkNotebooks) {
176 for (const p of paths.filter(isNotebook).slice(0, MAX_READS)) {
177 const text = await body(p)
178 if (text !== undefined && notebookHasOutputs(text)) notebookPaths.push(p)
179 }
180 findings.notebooks = notebookPaths
181 }
182 return { findings, notebookPaths, top }
183}
184
185// ---- asking -------------------------------------------------------------------------------------------------------
186
187async function ask($: Dollar, text: string, hasNotebooks: boolean): Promise<string> {
188 const surfaces = await safe(() => $.session.surfaces())
189 if (surfaces !== undefined && surfaces.length === 0) return HOLD // nobody to ask
190 try {
191 // Hold it first: it is the option the dialog opens on, so Enter and Esc both mean no.
192 return await $.ui.ask(text, { options: hasNotebooks ? [HOLD, COMMIT, FIX, STRIP] : [HOLD, COMMIT, FIX], header: 'Data guard' })
193 } catch {
194 return HOLD
195 }
196}
197
198/** Rewrites the notebooks without outputs and stages them. Returns the paths that could not be done. */
199async function strip($: Dollar, top: string, paths: string[]): Promise<string[]> {
200 const failed: string[] = []
201 const done: string[] = []
202 for (const p of paths) {
203 const text = await safe(() => $.fs.read(`${top}/${p}`))
204 const out = typeof text === 'string' ? stripNotebook(text) : undefined
205 if (out === undefined) {
206 failed.push(p)
207 continue
208 }
209 if (out !== text) await $.fs.write(`${top}/${p}`, out)
210 done.push(p)
211 }
212 if (done.length) {
213 const r = await $.process.run(['git', 'add', '--', ...done], { cwd: top, timeoutMs: GIT_TIMEOUT_MS })
214 if (r.exitCode !== 0) failed.push(...done)
215 }
216 return failed
217}
218
219const deny = (why: string) => ({ deny: why }) as never
220
221// ---- the command --------------------------------------------------------------------------------------------------
222
223const WORDS = new Set(['', 'status', 'on', 'off'])
224
225async function runCommand($: Dollar, cfg: Cfg, args: string): Promise<string> {
226 const word = args.trim().toLowerCase().replace(/\.$/, '')
227 if (!WORDS.has(word)) return `Data guard · "${args.trim().slice(0, 30)}" is not an option · use /data-guard on, off or status`
228 if (word === 'on' || word === 'off') {
229 if (!cfg.isEnabled) return 'Data guard · disabled by the enabled setting (change it in /config)'
230 try {
231 await setSwitch($, word === 'off')
232 } catch {
233 return 'Data guard · that did not work, nothing changed'
234 }
235 }
236 const off = !cfg.isEnabled || (await isOff($))
237 const t = await safe(() => read($, tallyAtom))
238 return [
239 `Data guard · ${off ? 'off' : 'on'}${cfg.isEnabled ? '' : ' (enabled setting is false)'}`,
240 `Large file limit ${cfg.maxMb} MB · notebooks ${cfg.checkNotebooks ? 'checked' : 'not checked'} · columns ${cfg.checkColumns ? 'checked' : 'not checked'}`,
241 `This session: ${t?.checked ?? 0} checked · ${t?.asked ?? 0} asked · ${t?.held ?? 0} held · ${t?.stripped ?? 0} notebooks stripped`,
242 ].join('\n')
243}
244
245export const register: Register = (on, options) => {
246 const cfg = readConfig(options)
247
248 on('session.start', async ($, e, next) => {
249 const stored = (await safe(() => $.store.get('off'))) === true
250 await safe(() => update($, isOffAtom, () => stored))
251 await safe(() =>
252 $.command.register({ name: COMMAND, description: 'Ask before committing large, data or personal-looking files: /data-guard [on|off|status]', argumentHint: '[on|off|status]' }),
253 )
254 return next(e)
255 })
256
257 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, cfg, e.args) })).catch(() => ({ text: 'Data guard · could not read its own state' }))
258
259 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
260 if (!cfg.isEnabled || (await isOff($))) return next(e)
261 const plan = planOf(e.command)
262 if (!plan.commit && plan.adds.length === 0) return next(e)
263 await count($, 'checked')
264
265 const seen = await inspect($, plan, cfg)
266 if (!seen || !hasAny(seen.findings)) return next(e)
267
268 const f = seen.findings
269 await count($, 'asked')
270 const verb = plan.commit ? 'git commit' : 'git add'
271 const answer = await ask($, questionText(f, verb, cfg.maxMb), seen.notebookPaths.length > 0)
272
273 if (answer === COMMIT) return next(e)
274 if (answer === STRIP && seen.notebookPaths.length > 0) {
275 const failed = await strip($, seen.top, seen.notebookPaths)
276 if (failed.length) {
277 await count($, 'held')
278 return deny(`Not run: could not strip ${failed.length} notebook(s): ${failed.slice(0, 3).join(', ')}.`)
279 }
280 await count($, 'stripped', seen.notebookPaths.length)
281 if (f.large.length + f.data.length + f.columns.length === 0) return next(e)
282 await count($, 'held')
283 return deny('Notebook outputs were stripped and staged. Not run: large, data or personal-looking files are still in it. Fix those or run the command again and choose Commit anyway.')
284 }
285 await count($, 'held')
286 if (answer === FIX) return deny(fixText(f))
287 return deny('Not run: held by the person in the Data guard dialog. Nothing was committed or staged.')
288 }).catch(($, e, next) => next(e)) // FAIL OPEN: a guard that breaks must not wedge git
289}
290
291hooks/detect.ts 261 lines1// Pure helpers: reading a git command, the file lists git prints, and the checks. Nothing here touches the engine,
2// and nothing returns a file's content: only names, paths and counts.
3
4export const DATA_EXTENSIONS = [
5 'csv', 'tsv', 'parquet', 'feather', 'rds', 'rdata', 'h5', 'hdf5', 'xlsx', 'xls', 'sav', 'dta', 'sqlite', 'db', 'pkl', 'npy', 'npz', 'zip', 'gz',
6] as const
7
8const DATA_SET = new Set<string>(DATA_EXTENSIONS)
9
10export const extOf = (path: string): string => {
11 const name = path.slice(path.lastIndexOf('/') + 1)
12 const dot = name.lastIndexOf('.')
13 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
14}
15export const isDataLike = (path: string): boolean => DATA_SET.has(extOf(path))
16export const isNotebook = (path: string): boolean => extOf(path) === 'ipynb'
17export const isTable = (path: string): boolean => extOf(path) === 'csv' || extOf(path) === 'tsv'
18
19// ---- the command ---------------------------------------------------------------------------------------------------
20
21export type AddSpec = { isAll: boolean; isTrackedOnly: boolean; paths: string[] }
22export type GitPlan = { commit: { isAll: boolean } | undefined; adds: AddSpec[] }
23
24/** Splits on unquoted && || ; | & and newlines, then into words (quotes removed). Best effort, never throws. */
25export function segments(command: string): string[][] {
26 const out: string[][] = []
27 let words: string[] = []
28 let cur = ''
29 let has = false
30 let quote = ''
31 const endWord = () => {
32 if (has) words.push(cur)
33 cur = ''
34 has = false
35 }
36 const endSeg = () => {
37 endWord()
38 if (words.length) out.push(words)
39 words = []
40 }
41 for (let i = 0; i < command.length; i++) {
42 const c = command[i]!
43 if (quote) {
44 if (c === quote) quote = ''
45 else if (c === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]
46 else cur += c
47 } else if (c === '"' || c === "'") {
48 quote = c
49 has = true
50 } else if (c === '\\' && i + 1 < command.length) {
51 cur += command[++i]
52 has = true
53 } else if (c === ';' || c === '|' || c === '&' || c === '\n' || c === '(' || c === ')') endSeg()
54 else if (c === ' ' || c === '\t') endWord()
55 else {
56 cur += c
57 has = true
58 }
59 }
60 endSeg()
61 return out
62}
63
64const GLOBAL_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
65
66/** Finds the `git commit` and `git add` parts of a command line. */
67export function planOf(command: string): GitPlan {
68 const plan: GitPlan = { commit: undefined, adds: [] }
69 for (const words of segments(command)) {
70 let i = 0
71 while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) || words[i] === 'env' || words[i] === 'command')) i++
72 const bin = words[i]
73 if (bin !== 'git' && !(bin ?? '').endsWith('/git')) continue
74 i++
75 while (i < words.length && words[i]!.startsWith('-')) i += GLOBAL_WITH_ARG.has(words[i]!) ? 2 : 1
76 const sub = words[i]
77 const rest = words.slice(i + 1)
78 if (sub === 'commit') {
79 const isAll = rest.some(w => w === '--all' || (/^-[A-Za-z]+$/.test(w) && /^-[^mFCcStu]*a/.test(w)))
80 plan.commit = { isAll: (plan.commit?.isAll ?? false) || isAll }
81 } else if (sub === 'add') {
82 let isAll = false
83 let isTrackedOnly = false
84 let skip = false
85 let afterDashes = false
86 const paths: string[] = []
87 for (const w of rest) {
88 if (afterDashes) paths.push(w)
89 else if (w === '--') afterDashes = true
90 else if (w === '-A' || w === '--all') isAll = true
91 else if (w === '-u' || w === '--update') isTrackedOnly = true
92 else if (w === '-n' || w === '--dry-run' || w === '-p' || w === '--patch' || w === '-i' || w === '--interactive' || w === '-e' || w === '--edit') skip = true
93 else if (!w.startsWith('-')) paths.push(w)
94 }
95 if (!skip) plan.adds.push({ isAll, isTrackedOnly, paths })
96 }
97 }
98 return plan
99}
100
101// ---- what git prints -----------------------------------------------------------------------------------------------
102
103export type Entry = { status: string; path: string }
104
105/** `git diff --name-status -z`: `M\0path\0`, renames and copies `R100\0old\0new\0`. Deleted files are dropped. */
106export function parseNameStatus(out: string): Entry[] {
107 const parts = out.split('\0')
108 const res: Entry[] = []
109 for (let i = 0; i < parts.length; ) {
110 const status = parts[i++] ?? ''
111 if (status === '') continue
112 const two = status[0] === 'R' || status[0] === 'C'
113 const path = two ? (i++, parts[i++]) : parts[i++]
114 if (path && status[0] !== 'D') res.push({ status: status[0]!, path })
115 }
116 return res
117}
118
119/** `git status --porcelain -z`: `XY path\0`, a rename adds `orig\0`. Deleted files are dropped. */
120export function parsePorcelain(out: string): { x: string; y: string; path: string }[] {
121 const parts = out.split('\0')
122 const res: { x: string; y: string; path: string }[] = []
123 for (let i = 0; i < parts.length; i++) {
124 const p = parts[i]!
125 if (p.length < 4) continue
126 const x = p[0]!
127 const y = p[1]!
128 if (x === 'R' || x === 'C') i++
129 if (x !== 'D' && y !== 'D') res.push({ x, y, path: p.slice(3) })
130 }
131 return res
132}
133
134const clean = (p: string): string => {
135 const out: string[] = []
136 for (const part of p.split('/')) {
137 if (part === '' || part === '.') continue
138 if (part === '..') out.pop()
139 else out.push(part)
140 }
141 return out.join('/')
142}
143
144const globToRe = (g: string): RegExp =>
145 new RegExp(
146 '^' +
147 g
148 .replace(/[.+^${}()|\\]/g, '\\$&')
149 .replace(/\*\*/g, '\u0000')
150 .replace(/\*/g, '[^/]*')
151 .replace(/\?/g, '[^/]')
152 .replace(/\u0000/g, '.*') +
153 '$',
154 )
155
156/** Does `git add <spec>`, run from `prefix` (the folder under the repo root, `sub/` or ''), name this repo-relative path? */
157export function addNames(spec: string, prefix: string, path: string): boolean {
158 const joined = clean(spec.startsWith(':') ? spec.replace(/^:(\([^)]*\)|[!^/]*)/, '') : `${prefix}/${spec}`)
159 if (joined === '') return true
160 if (/[*?[]/.test(joined)) return globToRe(joined).test(path) || globToRe(`${joined}/**`).test(path)
161 return path === joined || path.startsWith(`${joined}/`)
162}
163
164// ---- checks --------------------------------------------------------------------------------------------------------
165
166const PERSONAL: RegExp[] = [
167 /(^|_)e_?mail($|_)/, /(^|_)ssn($|_)/, /(^|_)dob($|_)/, /birth/, /(^|_)phone($|_)/, /(^|_)telephone($|_)/, /address/, /patient/, /(^|_)mrn($|_)/,
168 /(^|_)(first|last|sur|full|given|family|middle)_?name($|_)/, /(^|_)surname($|_)/, /^(firstname|lastname|fullname)$/,
169]
170
171const normalise = (c: string): string =>
172 c
173 .replace(/^/, '')
174 .trim()
175 .replace(/^["']+|["']+$/g, '')
176 .replace(/([a-z])([A-Z])/g, '$1_$2')
177 .toLowerCase()
178 .replace(/[^a-z0-9]+/g, '_')
179 .replace(/^_+|_+$/g, '')
180
181/** The column names in a table's first line that look like personal data (as written, cut to 30 characters). */
182export function personalColumns(firstLine: string): string[] {
183 const sep = firstLine.includes('\t') ? '\t' : firstLine.includes(';') && !firstLine.includes(',') ? ';' : ','
184 const hits: string[] = []
185 for (const raw of firstLine.replace(/\r$/, '').split(sep)) {
186 const n = normalise(raw)
187 if (n !== '' && PERSONAL.some(re => re.test(n))) hits.push(raw.replace(/^/, '').trim().replace(/^["']+|["']+$/g, '').slice(0, 30))
188 }
189 return hits
190}
191
192/** The first line of a text, and nothing else of it. */
193export const firstLineOf = (text: string): string => {
194 const i = text.indexOf('\n')
195 return (i < 0 ? text : text.slice(0, i)).slice(0, 4000)
196}
197
198type Cell = { cell_type?: string; outputs?: unknown; execution_count?: unknown }
199type Notebook = { cells?: Cell[] }
200
201const parseNb = (text: string): Notebook | undefined => {
202 try {
203 const nb = JSON.parse(text) as unknown
204 return nb && typeof nb === 'object' && Array.isArray((nb as Notebook).cells) ? (nb as Notebook) : undefined
205 } catch {
206 return undefined
207 }
208}
209
210/** True when a code cell holds an output or an execution count. A text that is not a notebook is false. */
211export function notebookHasOutputs(text: string): boolean {
212 const nb = parseNb(text)
213 return !!nb?.cells?.some(c => c && typeof c === 'object' && ((Array.isArray(c.outputs) && c.outputs.length > 0) || (c.execution_count !== undefined && c.execution_count !== null)))
214}
215
216/** The notebook without outputs and execution counts, as nbformat writes it (indent 1, trailing newline); undefined when it is not one. */
217export function stripNotebook(text: string): string | undefined {
218 const nb = parseNb(text)
219 if (!nb) return undefined
220 for (const c of nb.cells!) {
221 if (!c || typeof c !== 'object' || c.cell_type !== 'code') continue
222 c.outputs = []
223 c.execution_count = null
224 }
225 return `${JSON.stringify(nb, null, 1)}\n`
226}
227
228// ---- the question --------------------------------------------------------------------------------------------------
229
230export type Findings = {
231 large: { path: string; bytes: number }[]
232 data: string[]
233 columns: { path: string; names: string[] }[]
234 notebooks: string[]
235}
236
237export const hasAny = (f: Findings): boolean => f.large.length + f.data.length + f.columns.length + f.notebooks.length > 0
238
239const MB = 1024 * 1024
240export const mbText = (bytes: number): string => `${(bytes / MB).toFixed(bytes >= 10 * MB ? 0 : 1)} MB`
241const list = (items: string[], max = 4): string => (items.length <= max ? items.join(', ') : `${items.slice(0, max).join(', ')} +${items.length - max} more`)
242
243export function questionText(f: Findings, verb: string, maxMb: number): string {
244 const lines = ['Data guard found something in what is about to be saved:']
245 if (f.large.length) lines.push(`• Large (over ${maxMb} MB): ${list(f.large.map(l => `${l.path} ${mbText(l.bytes)}`))}`)
246 if (f.data.length) lines.push(`• Data files, not ignored or in LFS: ${list(f.data)}`)
247 for (const c of f.columns.slice(0, 3)) lines.push(`• Personal-looking columns in ${c.path}: ${list(c.names, 5)}`)
248 if (f.columns.length > 3) lines.push(`• Personal-looking columns in ${f.columns.length - 3} more files`)
249 if (f.notebooks.length) lines.push(`• Notebooks with outputs: ${list(f.notebooks)}`)
250 lines.push('', `Run this ${verb}?`)
251 return lines.join('\n')
252}
253
254export const fixText = (f: Findings): string => {
255 const steps: string[] = []
256 if (f.large.length || f.data.length) steps.push('unstage them (git restore --staged <file>) and add them to .gitignore, or track them with git lfs')
257 if (f.columns.length) steps.push('remove or anonymise the personal columns')
258 if (f.notebooks.length) steps.push('clear the notebook outputs (or choose Strip outputs next time)')
259 return `Not run: the person chose to fix it first. Suggested: ${steps.join('; ')}.`
260}
261types/index.d.ts 22 lines1/** What data-guard counted this session. Plain numbers; never a path or a value. */
2export type DataGuardTally = {
3 /** git commit / git add commands looked at. */
4 checked: number
5 /** Questions asked. */
6 asked: number
7 /** Commands held (Hold it, Fix it, Esc). */
8 held: number
9 /** Notebooks stripped of outputs. */
10 stripped: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'data-guard': {
16 /** The persisted on/off switch ($.store 'off'), mirrored here so the moddeck side bar can read it. */
17 isOff: boolean
18 tally: DataGuardTally
19 }
20 }
21}
22