@sriinnu/omit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,21 @@ import { assessCommand } from '../lib/danger.mjs'
7
7
 
8
8
  if (process.env.OMIT_OFF === '1') process.exit(0)
9
9
 
10
+ // Claude Code's hook contract: exit 2 blocks the tool call, and every other
11
+ // non-zero code is a NON-BLOCKING error — the model sees it and can carry on.
12
+ // So the only safe exit for a hook that did not finish its job is 2. A crash at
13
+ // exit 1 is this hook silently not running, which is the one outcome it exists
14
+ // to prevent; a non-string `command` used to throw out of assessCommand and let
15
+ // the command through.
16
+ const block = (message) => {
17
+ console.error(message)
18
+ process.exit(2)
19
+ }
20
+
21
+ // The payload comes from the harness, not the agent, so a payload we cannot even
22
+ // parse is not evidence about anything the agent did — and harnesses do not agree
23
+ // on shape (Codex's apply_patch sends different fields). Skip, do not block the
24
+ // session on it.
10
25
  let data
11
26
  try {
12
27
  data = JSON.parse(readFileSync(0, 'utf8'))
@@ -14,16 +29,29 @@ try {
14
29
  process.exit(0)
15
30
  }
16
31
 
17
- const command = data.tool_input?.command
18
- if (!command) process.exit(0)
32
+ try {
33
+ const command = data.tool_input?.command
34
+ if (command === undefined || command === null || command === '') process.exit(0) // not a shell tool call
35
+ if (typeof command !== 'string') {
36
+ block(
37
+ 'omit blocks this command: its text was not a string, so nothing about it could be assessed.\n' +
38
+ 'Send the command as a string, or set OMIT_OFF=1 to run without the sentinel.'
39
+ )
40
+ }
19
41
 
20
- const findings = assessCommand(command)
21
- if (findings.length === 0) process.exit(0)
42
+ const findings = assessCommand(command)
43
+ if (findings.length === 0) process.exit(0)
22
44
 
23
- console.error(
24
- 'omit blocks this command: the machine is load-bearing.\n' +
25
- findings.map((f) => ` [${f.rule}] ${f.reason}`).join('\n') +
26
- '\nUse a narrower target inside the workspace, guard variables with ${VAR:?}, ' +
27
- 'or, only after genuine review with the user, append: # omit-allow: <reason>'
28
- )
29
- process.exit(2)
45
+ console.error(
46
+ 'omit blocks this command: the machine is load-bearing.\n' +
47
+ findings.map((f) => ` [${f.rule}] ${f.reason}`).join('\n') +
48
+ '\nUse a narrower target inside the workspace, guard variables with ${VAR:?}, ' +
49
+ 'or, only after genuine review with the user, append: # omit-allow: <reason>'
50
+ )
51
+ process.exit(2)
52
+ } catch (e) {
53
+ block(
54
+ `omit could not assess this command (${e?.message ?? e}).\n` +
55
+ 'Blocking the command rather than waving it through: a check that did not run is not a check that passed.'
56
+ )
57
+ }
@@ -1,45 +1,199 @@
1
1
  #!/usr/bin/env node
2
- // PostToolUse hook: raises a blocking objection when a new dependency lands
3
- // in a manifest without a receipt in .omit/receipts.jsonl.
4
- import { readFileSync, existsSync } from 'node:fs'
5
- import { execSync } from 'node:child_process'
6
- import { basename, join } from 'node:path'
7
- import { isManifest, addedDeps } from '../lib/deps.mjs'
2
+ // PostToolUse hook: raises a blocking objection when a new dependency lands in
3
+ // a manifest without a receipt in .omit/receipts.jsonl.
4
+ //
5
+ // Fires on file edits (Edit|Write|MultiEdit|NotebookEdit) and on Bash: a
6
+ // manifest is also rewritten by `npm pkg set`, a redirect, `tee` or `sed -i`,
7
+ // and the same change through a different tool is the same change.
8
+ import { readFileSync, realpathSync } from 'node:fs'
9
+ import { basename, resolve } from 'node:path'
10
+ import { isManifest, addedDeps, MANIFESTS } from '../lib/deps.mjs'
11
+ import { probe, fileAtRevision, repoRelPath } from '../lib/git.mjs'
12
+ import { execDisabled, flagOn } from '../lib/exec.mjs'
13
+ import { newDepCitations } from '../lib/receipts.mjs'
8
14
 
9
15
  if (process.env.OMIT_OFF === '1') process.exit(0)
10
16
 
17
+ // Exit 2 blocks the tool call; any other non-zero code is a non-blocking error
18
+ // the model can shrug off. This hook's whole value is the objection, so nothing
19
+ // here is allowed to end in an uncaught throw.
20
+ const block = (message) => {
21
+ console.error(message)
22
+ process.exit(2)
23
+ }
24
+
25
+ // The ledger is executable in the Makefile sense, and `.omit/receipts.jsonl` is
26
+ // a file the agent under check can Write. Confirmed: a Write of the manifest plus
27
+ // a Write of the ledger ran the receipt's `run` argv as the developer from this
28
+ // PostToolUse hook — no Bash call, no permission prompt, exit 0. So the hook
29
+ // declines to execute ledger snippets by default and the operator opts in per
30
+ // project. OMIT_NO_EXEC is not set here but in the environment of this process,
31
+ // which is the same flag lib/receipts.mjs `execDisabled()` reads; the off-values
32
+ // are kept identical on purpose ( '', '0', 'false' mean "the flag is off" ), so
33
+ // OMIT_HOOK_EXEC=0 really does mean no execution.
34
+ const mayExec = flagOn(process.env.OMIT_HOOK_EXEC)
35
+ if (!mayExec) process.env.OMIT_NO_EXEC = '1'
36
+
37
+ // repoRoot() answers null both for "not a repository" and for "git could not
38
+ // answer", and those want opposite exits: no repository means there is no HEAD
39
+ // to compare against, while a git that cannot run at all (missing binary, dubious
40
+ // ownership, unreadable object store) means the check never happened. git's own
41
+ // message is the only thing that separates the two, so match on it and fail
42
+ // closed on everything else.
43
+ function repoRootOrNull(cwd) {
44
+ const r = probe(cwd, ['rev-parse', '--show-toplevel'])
45
+ if (r.ok) return r.out.trim()
46
+ if (/not a git repository|must be run in a work tree/i.test(String(r.reason))) return null
47
+ throw new Error(`git could not report the repository root (${r.reason})`)
48
+ }
49
+
50
+ // The manifests a shell command plausibly rewrote, decided by pure string work:
51
+ // this runs on every Bash call, so nothing may spawn a process until a manifest
52
+ // is actually in play. Two signals — a package manager whose subcommand writes a
53
+ // manifest, and a watched manifest filename anywhere in the command (which is
54
+ // what `cat > package.json`, `tee`, `sed -i` and `cp` look like).
55
+ const MANAGER_WRITES = [
56
+ [/\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|remove|rm|uninstall|upgrade|update|pkg|link)\b/, 'package.json'],
57
+ [/\b(?:poetry|uv|pipenv)\s+(?:add|remove|install)\b/, 'pyproject.toml'],
58
+ [/\bcargo\s+(?:add|remove|install)\b/, 'Cargo.toml'],
59
+ [/\bgo\s+(?:get|mod\s+(?:tidy|edit))\b/, 'go.mod'],
60
+ [/\b(?:bundle|bundler)\s+(?:add|install|update)\b/, 'Gemfile'],
61
+ [/\bcomposer\s+(?:require|remove|update|install)\b/, 'composer.json'],
62
+ ]
63
+
64
+ function manifestsTouched(command) {
65
+ const out = new Set()
66
+ for (const [re, name] of MANAGER_WRITES) if (re.test(command)) out.add(name)
67
+ for (const name of MANIFESTS) {
68
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
69
+ const m = command.match(new RegExp(`(?:^|[\\s'"=<>|(])([\\w./\\\\-]*${escaped})`, 'i'))
70
+ if (m) out.add(m[1])
71
+ }
72
+ return [...out]
73
+ }
74
+
75
+ // repoRelPath is lexical, and the path the harness sends and the root git
76
+ // reports do not always agree on symlinks: on macOS `/tmp` and `/var` are links
77
+ // into `/private`, and git answers with the resolved root. A mismatch reads as
78
+ // "outside the repo" — which exits 0, a manifest edit silently unaudited. So
79
+ // resolve the file and ask again before giving up on it.
80
+ function relInRoot(root, abs) {
81
+ const lexical = repoRelPath(root, abs)
82
+ if (lexical !== null) return lexical
83
+ try {
84
+ return repoRelPath(root, realpathSync(abs))
85
+ } catch {
86
+ return null // no such file: nothing to compare, nothing added
87
+ }
88
+ }
89
+
90
+ // A manifest we cannot read as text declares nothing, so nothing was added to it.
91
+ // Deleting or replacing it with a directory is not a dependency landing.
92
+ function readManifest(abs) {
93
+ try {
94
+ return readFileSync(abs, 'utf8')
95
+ } catch (e) {
96
+ if (e.code === 'ENOENT' || e.code === 'EISDIR') return null
97
+ throw e
98
+ }
99
+ }
100
+
11
101
  let data
12
102
  try {
13
103
  data = JSON.parse(readFileSync(0, 'utf8'))
14
104
  } catch {
15
- process.exit(0)
105
+ process.exit(0) // harness-authored payload we cannot read: not evidence about a file
16
106
  }
17
107
 
18
- const file = data.tool_input?.file_path
19
- if (!file || !isManifest(file)) process.exit(0)
20
-
21
- const cwd = data.cwd ?? process.cwd()
22
- let diff = ''
23
108
  try {
24
- diff = execSync(`git diff HEAD -- "${file}"`, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
25
- } catch {
26
- process.exit(0) // not a git repo: nothing to compare against
27
- }
109
+ const ti = data.tool_input ?? {}
110
+ for (const f of ['file_path', 'notebook_path', 'command']) {
111
+ if (ti[f] !== undefined && typeof ti[f] !== 'string') {
112
+ block(
113
+ `omit blocks this edit: the hook was handed a \`${f}\` that is not a path or a command, so no manifest behind it could be checked.\n` +
114
+ 'Send the tool input as a string, or set OMIT_OFF=1 to run without the sentinel.'
115
+ )
116
+ }
117
+ }
118
+
119
+ const cwd = data.cwd ?? process.cwd()
120
+ const targets =
121
+ typeof ti.file_path === 'string' ? (isManifest(ti.file_path) ? [ti.file_path] : []) : typeof ti.command === 'string' ? manifestsTouched(ti.command) : []
122
+ if (targets.length === 0) process.exit(0) // an edit that cannot be a manifest edit
28
123
 
29
- const deps = addedDeps(basename(file), diff)
30
- if (deps.length === 0) process.exit(0)
124
+ const root = repoRootOrNull(cwd)
125
+ if (root === null) process.exit(0) // not a repository: no history to compare against
31
126
 
32
- let receipts = ''
33
- const ledger = join(cwd, '.omit', 'receipts.jsonl')
34
- if (existsSync(ledger)) receipts = readFileSync(ledger, 'utf8')
127
+ const citations = newDepCitations(root)
128
+ const uncited = new Map()
129
+ for (const target of targets) {
130
+ const abs = resolve(cwd, target)
131
+ const rel = relInRoot(root, abs)
132
+ if (rel === null) continue // outside the repo: not ours to audit
133
+ const after = readManifest(abs)
134
+ if (after === null) continue
135
+ const before = fileAtRevision(root, 'HEAD', rel)
136
+ // `{failed:true}` is not an empty file. Rendering it as one would turn every
137
+ // dependency in the manifest into a "new" one, or hide the added one
138
+ // entirely: either way the hook would be reporting on a comparison it never
139
+ // made. Throw, and let the wrapper object.
140
+ if (before.failed) throw new Error(`git could not read ${rel} at HEAD (${before.reason})`)
35
141
 
36
- const uncited = deps.filter((d) => !receipts.includes(d))
37
- if (uncited.length === 0) process.exit(0)
142
+ // Compare declared dependency SETS across the two revisions rather than
143
+ // parsing the diff: a brand-new manifest has no earlier revision, so
144
+ // everything in it is new, and a minified manifest declares the same set as
145
+ // a pretty-printed one.
146
+ for (const dep of addedDeps(basename(rel), before.present ? before.text : '', after)) {
147
+ const status = citations.get(dep)?.status
148
+ // Only a VERIFIED new-dep receipt cites a dependency — a ledger line that
149
+ // merely names it is an assertion, which is what this hook exists to stop
150
+ // accepting.
151
+ if (status !== 'verified' && !uncited.has(dep)) uncited.set(dep, status ?? null)
152
+ }
153
+ }
154
+ if (uncited.size === 0) process.exit(0)
38
155
 
39
- console.error(
40
- `omit objects: new dependenc${uncited.length > 1 ? 'ies' : 'y'} with no receipt: ${uncited.join(', ')}.\n` +
41
- `Omissions 2-5 come first: show the codebase, stdlib, platform, and installed deps cannot cover this.\n` +
42
- `If they truly cannot, append the citation to .omit/receipts.jsonl ` +
43
- `({"claim":"new dep ${uncited[0]} required","receipt":"<what you verified>","rung":7}) and re-apply. Otherwise revert the manifest change.`
44
- )
45
- process.exit(2)
156
+ const deps = [...uncited]
157
+ const why = deps
158
+ .map(([dep, status]) =>
159
+ status === 'failed'
160
+ ? ` ${dep}: its receipt was refuted by its own check`
161
+ : status === 'unverifiable'
162
+ ? ` ${dep}: its receipt could not be verified`
163
+ : ` ${dep}: no receipt names it`
164
+ )
165
+ .join('\n')
166
+
167
+ const decl = deps.length > 1 ? 'ies' : 'y'
168
+ console.error(
169
+ `omit objects: new dependenc${decl} without a verified receipt: ${deps.map(([d]) => d).join(', ')}.\n` +
170
+ why +
171
+ '\n' +
172
+ // Honest when it declines to execute: a receipt whose only evidence is a
173
+ // `run` snippet was not checked, and saying it is unverified is true where
174
+ // "verified" or silence would not be. It names which switch did the
175
+ // declining, so the reader is not told to set a flag they already set.
176
+ (execDisabled()
177
+ ? 'Ledger snippets were NOT executed here: ' +
178
+ (mayExec
179
+ ? 'OMIT_NO_EXEC is set in this environment.\n'
180
+ : 'a hook does not run `run` argv by default, because a Write of .omit/receipts.jsonl would\n' +
181
+ 'otherwise execute as you, with no approval prompt. Set OMIT_HOOK_EXEC=1 to let this hook run one.\n') +
182
+ 'A receipt whose evidence is a `run` snippet is unverified here, not refuted — check it with `omit verify`.\n'
183
+ : '') +
184
+ `Omissions 2-5 come first: show the codebase, stdlib, platform, and installed deps cannot cover this.\n` +
185
+ `If they truly cannot, append one line to .omit/receipts.jsonl — the entries under "tried" are re-checked, and must NOT hold:\n` +
186
+ // The printed line has to be a receipt that verifies. `absent` names a
187
+ // symbol the checker searches the whole tracked tree for, so replace it
188
+ // with the one you actually looked for; the claim survives only if that
189
+ // search comes back empty.
190
+ ` {"claim":"new-dep","rung":7,"dep":"${deps[0][0]}","tried":[{"rung":2,"absent":"<symbol you searched for>"}]}\n` +
191
+ `\`omit verify\` checks it before you commit. Otherwise revert the manifest change.`
192
+ )
193
+ process.exit(2)
194
+ } catch (e) {
195
+ block(
196
+ `omit could not complete the new-dependency check (${e?.message ?? e}).\n` +
197
+ 'Blocking the edit rather than accepting a manifest change nothing audited: a check that did not run is not a check that passed.'
198
+ )
199
+ }
@@ -1,52 +1,246 @@
1
1
  #!/usr/bin/env node
2
2
  // Stop hook: the turn may not end with an edited working tree and no Final Draft.
3
3
  // The deletion pass is a gate, not a suggestion.
4
- import { readFileSync, statSync, existsSync } from 'node:fs'
5
- import { execSync } from 'node:child_process'
6
- import { join } from 'node:path'
4
+ //
5
+ // The draft has to be a REPORT, not a file. Existence and mtime alone let a
6
+ // 0-byte .omit/final-draft.md satisfy a gate whose promise is that a session
7
+ // cannot end with an edited tree and no net report, so the counts the skill asks
8
+ // for — files touched, lines added/removed, new dependencies — are read out of
9
+ // the draft and cross-checked against the tree.
10
+ import { readFileSync, lstatSync, statSync } from 'node:fs'
11
+ import { basename, join } from 'node:path'
12
+ import { probe, git, fileAtRevision } from '../lib/git.mjs'
13
+ import { isManifest, addedDeps } from '../lib/deps.mjs'
7
14
 
8
15
  if (process.env.OMIT_OFF === '1') process.exit(0)
9
16
 
17
+ // Exit 2 blocks the stop (the model gets another turn); anything else non-zero is
18
+ // a non-blocking error and the session ends on an unchecked report.
19
+ const block = (message) => {
20
+ console.error(message)
21
+ process.exit(2)
22
+ }
23
+
10
24
  let data = {}
11
25
  try {
12
26
  data = JSON.parse(readFileSync(0, 'utf8'))
13
27
  } catch {}
14
28
  if (data.stop_hook_active) process.exit(0) // never loop the gate
15
29
 
16
- const cwd = data.cwd ?? process.cwd()
17
- let changed = []
18
- try {
19
- changed = execSync('git status --porcelain', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
20
- .split('\n')
21
- .filter(Boolean)
22
- .map((l) => l.slice(3).replace(/^.*-> /, ''))
23
- .filter((p) => !p.startsWith('.omit/'))
24
- } catch {
25
- process.exit(0)
26
- }
27
- // omitted: distinguishing this session's edits from pre-existing dirt: git has no
28
- // session concept; set OMIT_OFF=1 in dirty-tree workflows until a marker file lands.
29
- if (changed.length === 0) process.exit(0)
30
-
31
- const draft = join(cwd, '.omit', 'final-draft.md')
32
- if (existsSync(draft)) {
33
- const draftTime = statSync(draft).mtimeMs
34
- const newestEdit = Math.max(
35
- ...changed.map((p) => {
36
- try {
37
- return statSync(join(cwd, p)).mtimeMs
38
- } catch {
39
- return 0 // deleted file: cannot be newer than the draft
40
- }
41
- })
30
+ const DRAFT_MAX = 1 << 20
31
+
32
+ // repoRoot() answers null for "not a repository" and for "git could not answer",
33
+ // which want opposite exits — see dep-sentinel for the full note.
34
+ function repoRootOrNull(cwd) {
35
+ const r = probe(cwd, ['rev-parse', '--show-toplevel'])
36
+ if (r.ok) return r.out.trim()
37
+ if (/not a git repository|must be run in a work tree/i.test(String(r.reason))) return null
38
+ throw new Error(`git could not report the repository root (${r.reason})`)
39
+ }
40
+
41
+ // Text, or null when there is nothing to count (binary, unreadable, gone).
42
+ function readText(path) {
43
+ try {
44
+ const text = readFileSync(path, 'utf8')
45
+ return text.includes('\0') ? null : text
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ // omit counts a file's lines the way an editor does: a trailing newline ends the
52
+ // last line rather than starting an empty one.
53
+ const lineCount = (text) => {
54
+ const lines = text.split('\n')
55
+ if (lines[lines.length - 1] === '') lines.pop()
56
+ return lines.length
57
+ }
58
+
59
+ // Everything `omit audit` counts, per file: tracked changes (staged or not —
60
+ // `git diff HEAD` sees both) plus untracked files, which `git diff` cannot see
61
+ // and which are most of a working tree mid-session. Binary and unreadable files
62
+ // carry no line counts and are left out, which is the same choice audit makes —
63
+ // that is what lets a draft pasting audit's verdict match unchanged.
64
+ function changeSet(root) {
65
+ const rows = []
66
+ let binaries = 0
67
+ for (const line of git(root, ['diff', 'HEAD', '--numstat']).split('\n').filter(Boolean)) {
68
+ const [a, d, rel] = line.split('\t')
69
+ if (a === '-') {
70
+ binaries++
71
+ continue
72
+ }
73
+ rows.push({ rel, added: +a, deleted: +d, untracked: false })
74
+ }
75
+ for (const rel of git(root, ['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean)) {
76
+ // omit's own bookkeeping is not the change, and audit skips it the same way —
77
+ // that is what lets a draft pasting audit's verdict match this count.
78
+ // omitted: the same for a tracked .omit/ change, which audit does count; the
79
+ // caller excludes it from `changed` and keeps this set for that comparison.
80
+ if (rel.startsWith('.omit/')) continue
81
+ const text = readText(join(root, rel))
82
+ if (text === null) continue
83
+ rows.push({ rel, added: lineCount(text), deleted: 0, untracked: true })
84
+ }
85
+ return { rows, binaries }
86
+ }
87
+
88
+ // The counts the report is checked against. `files` is the size of the set, so
89
+ // every call here passes the set it wants counted.
90
+ function counts(root, rows) {
91
+ let added = 0
92
+ let deleted = 0
93
+ let deps = 0
94
+ for (const r of rows) {
95
+ added += r.added
96
+ deleted += r.deleted
97
+ if (!isManifest(r.rel)) continue
98
+ const after = readText(join(root, r.rel)) ?? '' // deleted or unreadable: declares nothing
99
+ const before = fileAtRevision(root, 'HEAD', r.rel)
100
+ // `{failed:true}` is not an empty manifest: reading it as one would turn
101
+ // every dependency it declares into a "new" one.
102
+ if (before.failed) throw new Error(`git could not read ${r.rel} at HEAD (${before.reason}) — the new-dependency count would be a guess`)
103
+ deps += addedDeps(basename(r.rel), before.present ? before.text : '', after).length
104
+ }
105
+ return { files: rows.length, added, deleted, deps }
106
+ }
107
+
108
+ // The draft is untrusted input like the ledger: a symlink to /dev/zero (or any
109
+ // pipe) would hang the Stop hook forever. lstat, not stat, so nothing is
110
+ // followed, and the size is capped rather than read into memory.
111
+ function readDraft(path) {
112
+ const st = lstatSync(path)
113
+ if (!st.isFile()) throw new Error(`${path} is not a regular file`)
114
+ if (st.size > DRAFT_MAX) throw new Error(`${path} is ${st.size} bytes, over the ${DRAFT_MAX}-byte cap`)
115
+ return { text: readFileSync(path, 'utf8'), mtimeMs: st.mtimeMs }
116
+ }
117
+
118
+ // Form is read leniently — audit's own verdict line parses, and so does prose like
119
+ // "files touched: 2" — content strictly: all three counts must be stated, because
120
+ // a report that cannot be checked is not a net report.
121
+ const firstInt = (text, patterns) => {
122
+ for (const re of patterns) {
123
+ const m = text.match(re)
124
+ if (m) return +m[1]
125
+ }
126
+ return null
127
+ }
128
+
129
+ const firstPair = (text, patterns) => {
130
+ for (const re of patterns) {
131
+ const m = text.match(re)
132
+ if (m) return [+m[1], +m[2]]
133
+ }
134
+ return null
135
+ }
136
+
137
+ function parseReport(text) {
138
+ const files = firstInt(text, [
139
+ /across\s+(\d+)\s+files?\b/i, // audit's line: "net: +3 −0 lines across 1 file"
140
+ /\bfiles?\s+(?:touched|changed|edited)\s*[:=]\s*\**(\d+)/i,
141
+ /\bfiles?\s*[:=]\s*\**(\d+)/i,
142
+ /(\d+)\s+files?\b/i,
143
+ ])
144
+ const lines = firstPair(text, [
145
+ /net\s*[:=]?\s*\+\s*(\d+)\s*[−–-]\s*(\d+)/i, // audit's line
146
+ /\+\s*(\d+)\s*[−–/-]\s*(\d+)/, // "+12 −3", "+12/-3"
147
+ /\blines?\b[^\n]{0,32}?(\d+)\s*[−–/-]\s*(\d+)/i,
148
+ ])
149
+ const none = /\bno\s+new\s+(?:deps?|dependencies)\b|\bnew\s+(?:deps?|dependencies)\s*[:=]\s*\**\s*(?:none|zero|nil|n\/a)\b/i
150
+ const deps = none.test(text)
151
+ ? 0
152
+ : firstInt(text, [/\bnew\s+(?:deps?|dependencies)\s*[:=]\s*\**(\d+)/i, /\bnew\s+dependenc\w*\s*[:=]\s*\**(\d+)/i, /\bdependenc\w*\s+(?:added|introduced)\s*[:=]?\s*(\d+)/i])
153
+ return { files, lines, deps }
154
+ }
155
+
156
+ function main() {
157
+ const cwd = data.cwd ?? process.cwd()
158
+ const root = repoRootOrNull(cwd)
159
+ if (root === null) process.exit(0) // not a repository: there is no diff to gate
160
+
161
+ const { rows, binaries } = changeSet(root)
162
+ const changed = rows.filter((r) => !r.rel.startsWith('.omit/'))
163
+ // omitted: distinguishing this session's edits from pre-existing dirt: git has no
164
+ // session concept; set OMIT_OFF=1 in dirty-tree workflows until a marker file lands.
165
+ if (changed.length === 0) process.exit(0)
166
+
167
+ const draftPath = join(root, '.omit', 'final-draft.md')
168
+ let draft
169
+ try {
170
+ draft = readDraft(draftPath)
171
+ } catch (e) {
172
+ block(
173
+ `omit: the working tree changed but there is no readable .omit/final-draft.md${e.code === 'ENOENT' ? '' : ` (${e.message})`}.\n` +
174
+ 'Working code is a first draft. Edit your own diff once (dead branches, unused params/imports, ' +
175
+ 'speculative options, restating comments, single-caller indirection), then write the net report to ' +
176
+ '.omit/final-draft.md: files touched, lines +/-, new dependencies, footnotes recorded. Then finish.'
177
+ )
178
+ }
179
+
180
+ // A draft older than the newest edit describes a tree that no longer exists.
181
+ const newestEdit = Math.max(...changed.map((r) => statSync(join(root, r.rel), { throwIfNoEntry: false })?.mtimeMs ?? 0))
182
+ if (draft.mtimeMs < newestEdit) {
183
+ block(
184
+ 'omit: the Final Draft is older than the newest edit — it reports a tree that no longer exists.\n' +
185
+ 'Re-run `omit audit`, update the counts in .omit/final-draft.md, then finish.'
186
+ )
187
+ }
188
+
189
+ const actual = counts(root, changed)
190
+ const report = parseReport(draft.text)
191
+ const missing = []
192
+ if (report.files === null) missing.push('"files touched: <n>"')
193
+ if (report.lines === null) missing.push('"lines +<added> −<removed>"')
194
+ if (report.deps === null) missing.push('"new dependencies: <n>"')
195
+ if (missing.length) {
196
+ block(
197
+ `omit: .omit/final-draft.md does not state ${missing.join(', ')}.\n` +
198
+ `The gate is on the report, not on the file existing. This tree right now: ${actual.files} file(s), ` +
199
+ `+${actual.added} −${actual.deleted} lines, ${actual.deps} new dependenc${actual.deps === 1 ? 'y' : 'ies'}.\n` +
200
+ 'Those are the numbers `omit audit` prints. Write them down, say what you cut, then finish.'
201
+ )
202
+ }
203
+
204
+ const wrong = []
205
+ if (report.files !== actual.files) wrong.push(` files touched: draft says ${report.files}, the tree has ${actual.files}`)
206
+ if (report.lines[0] !== actual.added || report.lines[1] !== actual.deleted) {
207
+ wrong.push(` lines: draft says +${report.lines[0]} −${report.lines[1]}, the tree has +${actual.added} −${actual.deleted}`)
208
+ }
209
+ if (report.deps !== actual.deps) wrong.push(` new dependencies: draft says ${report.deps}, the tree has ${actual.deps}`)
210
+ if (wrong.length === 0) {
211
+ if (binaries > 0) console.error(`omit: note — ${binaries} changed file(s) are binary; no line counts describe them.`)
212
+ process.exit(0)
213
+ }
214
+
215
+ // One honest mismatch passes with a note. `omit audit` counts omit's own ledger
216
+ // files when they are tracked, and this gate counts the change itself: a draft
217
+ // that pasted audit's verdict is reporting the tree accurately, and the only
218
+ // thing it includes that the gate excludes is omit's bookkeeping. Nothing a
219
+ // developer did is hidden by letting that through — which is not true of any
220
+ // other mismatch, where the draft is simply stating a number the tree
221
+ // contradicts.
222
+ const audited = counts(root, rows)
223
+ if (report.files === audited.files && report.lines[0] === audited.added && report.lines[1] === audited.deleted && report.deps === audited.deps) {
224
+ console.error(
225
+ "omit: note — the Final Draft matches `omit audit`'s counts, which include omit's own tracked files under .omit/.\n" +
226
+ 'The gate counts the change itself; the difference is omit\'s bookkeeping, not your work.'
227
+ )
228
+ process.exit(0)
229
+ }
230
+
231
+ block(
232
+ `omit: the Final Draft reports numbers the tree contradicts:\n${wrong.join('\n')}\n` +
233
+ `This tree right now: ${actual.files} file(s), +${actual.added} −${actual.deleted} lines, ${actual.deps} new dependenc${actual.deps === 1 ? 'y' : 'ies'}.\n` +
234
+ 'Fix the numbers in .omit/final-draft.md (untracked files count — `git diff --stat` cannot see them), then finish.'
42
235
  )
43
- if (draftTime >= newestEdit) process.exit(0)
44
236
  }
45
237
 
46
- console.error(
47
- 'omit: the working tree changed but there is no current Final Draft.\n' +
48
- 'Working code is a first draft. Edit your own diff once (dead branches, unused params/imports, ' +
49
- 'speculative options, restating comments, single-caller indirection), then write the net report to ' +
50
- '.omit/final-draft.md: files touched, lines +/-, new dependencies, footnotes recorded. Then finish.'
51
- )
52
- process.exit(2)
238
+ try {
239
+ main()
240
+ } catch (e) {
241
+ block(
242
+ `omit could not complete the Final Draft check (${e?.message ?? e}).\n` +
243
+ 'Blocking the stop rather than ending the session on a report nothing was compared against. ' +
244
+ 'Fix the error, or set OMIT_OFF=1 if this is not a working session to gate.'
245
+ )
246
+ }