@sriinnu/omit 0.5.0 → 0.6.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.
@@ -5,6 +5,11 @@ on:
5
5
  tags:
6
6
  - "v*.*.*"
7
7
 
8
+ # No npm token anywhere. npm's trusted publishing has this workflow prove who
9
+ # it is with a short-lived OIDC token GitHub mints for the run (that is what
10
+ # id-token: write allows), and npm checks it against the publisher configured
11
+ # for the package: sriinnu/omit, publish.yml. A stored token expired twice and
12
+ # stopped two releases after their tags were already pushed.
8
13
  permissions:
9
14
  contents: read
10
15
  id-token: write
@@ -17,9 +22,21 @@ jobs:
17
22
 
18
23
  - uses: actions/setup-node@v4
19
24
  with:
20
- node-version: "20"
25
+ node-version: "24"
21
26
  registry-url: "https://registry.npmjs.org"
22
27
 
28
+ # Trusted publishing needs npm 11.5.1 or later. An older npm does not
29
+ # fail by saying so: it sends no credentials and the registry answers
30
+ # 404, which reads as a missing package. Say it here instead.
31
+ - name: npm is new enough for trusted publishing
32
+ run: |
33
+ node -e '
34
+ const [major, minor, patch] = process.argv[1].split(".").map(Number)
35
+ const older = major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))
36
+ if (older) { console.error(`npm ${process.argv[1]} cannot do trusted publishing: it needs 11.5.1 or later`); process.exit(1) }
37
+ console.log(`npm ${process.argv[1]}`)
38
+ ' "$(npm --version)"
39
+
23
40
  - name: Verify tag matches package.json version
24
41
  run: |
25
42
  TAG_VERSION="${GITHUB_REF_NAME#v}"
@@ -44,10 +61,9 @@ jobs:
44
61
 
45
62
  - name: Publish
46
63
  if: steps.check.outputs.already_published == 'false'
47
- env:
48
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
49
64
  run: |
50
- if npm publish --access public --provenance; then
65
+ # The provenance attestation comes with trusted publishing; no flag.
66
+ if npm publish --access public; then
51
67
  echo "Publish succeeded."
52
68
  exit 0
53
69
  fi
package/README.md CHANGED
@@ -48,7 +48,9 @@ Every other skill in this genre is words the agent can ignore under context pres
48
48
  | **Final Draft gate** (hook) | The session cannot end with an edited tree and no current `.omit/final-draft.md` net report — and the report is read, not just stat'd. Its files/lines/deps counts are cross-checked against the actual diff, so a stub or a stale draft does not pass. The deletion pass is a gate, not a suggestion. |
49
49
  | **Receipts ledger** | Every Fact-Check citation is appended to `.omit/receipts.jsonl` as a claim *plus the evidence that settles it*, and `omit verify` re-checks the lot. Run it on a PR: "17/17 claims survived" is a number a reviewer can act on, and "3 refuted" names exactly which shortcuts were invented. |
50
50
 
51
- Hooks install automatically with the Claude Code plugin. Codex CLI has its own hooks system in the same shape (`PreToolUse` fires with `tool_input.command` for Bash, exit 2 blocks) — run `npx @sriinnu/omit hook install codex` to write `.codex/hooks.json`. The command and leak sentinels are verified against Codex's documented schema and payload shape (not yet a live Codex session firing them end-to-end); the file-based sentinels (dep/hazard/lint) and the Final Draft gate are wired too but best-effort, since Codex's `apply_patch` input shape for those isn't verified. Escape hatch for humans: `OMIT_OFF=1`.
51
+ Hooks install automatically with the Claude Code plugin. For Codex, run `npx @sriinnu/omit hook install codex` to merge hooks into `.codex/hooks.json`; rerunning preserves existing hooks without duplicating omit's entries. Command and leak sentinels check `Bash` before execution. Dependency and hazard sentinels also check shell edits afterward. File sentinels read `apply_patch` from `tool_input.command`, checking all added/updated paths and move destinations while skipping deleted files. Malformed patch envelopes produce an objection. Payload regression tests cover these contracts; live Codex delivery and Stop-gate behavior still require end-to-end verification. These hooks do not impose a token budget or compact transcripts. Escape hatch for humans: `OMIT_OFF=1`.
52
+
53
+ [Ribhu](https://github.com/sriinnu/ribhu) has shell hooks too, with its own file shape and payload: `npx @sriinnu/omit hook install ribhu` writes `.ribhu/hooks.json`, and a small adapter translates Ribhu's payload so the same sentinels run unchanged. Ribhu sends every tool call a code-mode script makes through those hooks, so a script's writes are checked like direct ones. The hooks are tested by firing each installed command the way Ribhu does, with payloads in its shape; they have not yet been watched firing inside a live Ribhu session.
52
54
 
53
55
  ## What this does not catch
54
56
 
@@ -112,6 +114,7 @@ npx @sriinnu/omit guard "<cmd>" # is this shell command a disaster? (wire into
112
114
  npx @sriinnu/omit leak "<cmd>" # would this command print a real secret to stdout?
113
115
  npx @sriinnu/omit gate # the pre-commit check, callable from anywhere
114
116
  npx @sriinnu/omit hook install codex # write .codex/hooks.json — live sentinels inside Codex CLI
117
+ npx @sriinnu/omit hook install ribhu # write .ribhu/hooks.json — live sentinels inside Ribhu
115
118
  ```
116
119
 
117
120
  And server-side, the GitHub Action comments the verdict on every PR regardless of what wrote the code:
@@ -128,7 +131,7 @@ jobs:
128
131
  steps:
129
132
  - uses: actions/checkout@v4
130
133
  with: { fetch-depth: 0 }
131
- - uses: sriinnu/omit@v0.5.0
134
+ - uses: sriinnu/omit@v0.6.0
132
135
  # with: { exec: true } # execute receipts' `run` snippets to verify them
133
136
  # fully. Off by default: a PR's receipts are
134
137
  # untrusted code, and this runs on pull requests.
@@ -211,6 +214,66 @@ Say `omit redline` (or any mode) in chat, or use `/omit <mode>` where slash comm
211
214
 
212
215
  ## Install
213
216
 
217
+ ### Hook health and context controls
218
+
219
+ These features run locally with Node and do not call a model or provider API.
220
+ Use the same CLI with any provider/model; hook installation targets the host,
221
+ not the model. Codex and Claude adapters are included. Other hosts need the
222
+ documented stdin/output contract below; automatic interception is not universal.
223
+
224
+ ```sh
225
+ omit doctor # read-only hook inspection; never runs discovered commands
226
+ omit doctor --json # same findings for scripts; exit 1 on errors
227
+ omit init skill # discoverable .agents/skills/omit/SKILL.md; never overwrite
228
+ omit hook install codex --context
229
+ omit hook install claude --context
230
+ ```
231
+
232
+ The doctor inspects global Codex hooks plus the current directory's
233
+ `.codex/hooks.json` and `.claude/settings.json`. It reports missing scripts,
234
+ duplicate registrations, malformed matchers, some legacy shell-tool name
235
+ mismatches, and Git `core.hooksPath` overrides. It checks simple Node/Python
236
+ script commands statically; other command shapes are explicitly unchecked.
237
+ It does not resolve all parent layers, host trust, executable availability, or
238
+ prove that hooks fired. `liveDelivery` remains `unverified`.
239
+
240
+ `--context` adds advisory broad-read warnings and a text output guard. Default
241
+ installations keep the existing safety hooks without adding context controls.
242
+ At more than 6,000 characters, a plain-string shell result is archived privately
243
+ under the OS temporary directory and replaced with head/tail excerpts, selected
244
+ error lines, and its full-output path. Set `OMIT_OUTPUT_CHARS` to 1000–100000 to
245
+ change the excerpt character budget. The wrapper/path adds a small overhead.
246
+ Structured results (objects, arrays, MCP content) are left intact. Storage
247
+ failure preserves the original result and emits a diagnostic. Archives can
248
+ contain sensitive output: files use mode 0600 inside a mode-0700 directory on
249
+ POSIX systems. They persist until temporary storage is cleaned; no existing
250
+ transcript is rewritten. Inspect the archive when a missing middle section
251
+ matters; diagnostic extraction is heuristic and not exhaustive.
252
+
253
+ Three consecutive identical command/result pairs in the same cwd/session
254
+ produce one advisory warning. A changed pair resets the counter. Only hashes
255
+ and a bounded counter are stored for this check; it does not prove filesystem
256
+ state is unchanged. Parallel invocations may undercount, so this is not a gate.
257
+
258
+ For another host, invoke `omit context` with JSON on stdin:
259
+
260
+ ```json
261
+ {"hook_event_name":"PostToolUse","tool_name":"Bash","session_id":"example","cwd":"/your/project","tool_input":{"command":"your command"},"tool_response":"plain text output"}
262
+ ```
263
+
264
+ Use `PreToolUse` for read-scope advice. Shell names `Bash`, `exec_command`, and
265
+ `shell` are accepted (`tool_input.cmd` is also accepted). `systemMessage` is
266
+ advisory; `decision: "block"` with `reason` requests post-tool feedback replacing
267
+ the result in Codex. Other hosts must translate that response into their own
268
+ replacement API: some only append feedback. Verify delivery and replacement
269
+ before claiming context savings. No model quota or token-saving guarantee is
270
+ inferred from character counts. Set `OMIT_OFF=1` to bypass the hooks; remove only
271
+ the `context-sentinel.mjs` entries to uninstall these optional controls.
272
+
273
+ The packaged skill remains at `skills/omit/SKILL.md`; `omit init skill` copies it
274
+ to the shared project discovery path. Hosts with different discovery paths can
275
+ install the same skill there. No provider credentials are required.
276
+
214
277
  New here? **[GETTING-STARTED.md](GETTING-STARTED.md)** has a copy-paste setup for every agent.
215
278
 
216
279
  **Claude Code (plugin marketplace)**: one command pair, gets you the skill plus `/omit` and `/omit-edit`:
@@ -294,9 +357,12 @@ Never `npm publish` by hand: a local publish cannot attach provenance, and
294
357
  npm will not let the same version be republished to add one later. If the CI
295
358
  publish fails, fix CI — don't work around it locally.
296
359
 
297
- If the `NPM_TOKEN` secret ever goes stale, put the new token in `~/.npmrc`
298
- and run `npm run token:sync` — it verifies the token against the registry
299
- before pushing, so a dead token never reaches CI.
360
+ There is no npm token to keep alive. `publish.yml` uses npm's
361
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers): the workflow
362
+ proves its identity to npm with a short-lived token GitHub mints for that run,
363
+ and npm checks it against the publisher configured in the package's settings
364
+ (GitHub Actions, `sriinnu/omit`, `publish.yml`). If a publish is refused, that
365
+ form is the thing to check.
300
366
 
301
367
  ## Prior art
302
368
 
package/bin/omit.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // omit leak "<cmd>" would this command print a real secret to stdout?
11
11
  // omit hook install add the gate to .git/hooks/pre-commit
12
12
  // omit hook install codex write .codex/hooks.json (live sentinels inside Codex CLI)
13
+ // omit hook install ribhu write .ribhu/hooks.json (live sentinels inside Ribhu)
13
14
  // omit codemode run [file] run one sandboxed script over read-only repo tools (stdin without a file)
14
15
  // omit codemode the same, as an MCP server on stdio
15
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, chmodSync } from 'node:fs'
@@ -23,6 +24,7 @@ import { lintFiles } from '../lib/lint.mjs'
23
24
  import { assessCommand } from '../lib/danger.mjs'
24
25
  import { assessLeak } from '../lib/leaks.mjs'
25
26
  import { execute, serve } from '../lib/codemode.mjs'
27
+ import { doctor } from '../lib/doctor.mjs'
26
28
 
27
29
  const cwd = process.cwd()
28
30
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
@@ -30,6 +32,7 @@ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
30
32
  // ---------- init ----------
31
33
  const targets = {
32
34
  agents: [['AGENTS.md', 'AGENTS.md']],
35
+ skill: [['skills/omit/SKILL.md', '.agents/skills/omit/SKILL.md']],
33
36
  claude: [[join('skills', 'omit', 'SKILL.md'), join('.claude', 'skills', 'omit', 'SKILL.md')]],
34
37
  cursor: [[join('.cursor', 'rules', 'omit.mdc'), join('.cursor', 'rules', 'omit.mdc')]],
35
38
  cline: [[join('.clinerules', 'omit.md'), join('.clinerules', 'omit.md')]],
@@ -513,18 +516,9 @@ function leak(args) {
513
516
  process.exit(1)
514
517
  }
515
518
 
516
- // Codex CLI's hook schema is the same shape as Claude Code's (confirmed against
517
- // developers.openai.com/codex/hooks: PreToolUse fires with tool_input.command for
518
- // Bash, exit 2 blocks). Command-sentinel and leak-sentinel run on that shape as-is.
519
- // The PostToolUse file hooks (dep/hazard/lint-sentinel) and the Stop gate are wired
520
- // too since Codex documents the same events, but Codex's apply_patch tool_input
521
- // shape for PostToolUse isn't confirmed here — those three no-op safely if the
522
- // file_path field isn't present, so this is best-effort, not verified parity.
523
- function hookInstallCodex() {
524
- const dir = '.codex'
525
- const hooksPath = join(dir, 'hooks.json')
526
- mkdirSync(dir, { recursive: true })
527
-
519
+ // An existing hooks file is merged into, never replaced: someone else's hooks
520
+ // live there too. One that cannot be read is refused rather than overwritten.
521
+ function loadHooksDoc(hooksPath) {
528
522
  let doc = { hooks: {} }
529
523
  if (existsSync(hooksPath)) {
530
524
  try {
@@ -537,10 +531,24 @@ function hookInstallCodex() {
537
531
  doc.hooks ??= {}
538
532
  for (const [event, entry] of Object.entries(doc.hooks)) {
539
533
  if (entry !== undefined && !Array.isArray(entry)) {
540
- console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array of matcher groups) — fix or remove it first`)
534
+ console.error(`omit: ${hooksPath} has a malformed "${event}" entry (expected an array) — fix or remove it first`)
541
535
  process.exit(1)
542
536
  }
543
537
  }
538
+ return doc
539
+ }
540
+
541
+ // Codex CLI's hook schema is the same shape as Claude Code's (confirmed against
542
+ // developers.openai.com/codex/hooks: PreToolUse fires with tool_input.command for
543
+ // Bash, exit 2 blocks). Command-sentinel and leak-sentinel run on that shape as-is.
544
+ // File sentinels extract surviving paths from Codex's apply_patch command.
545
+ // Contract tests cover these payloads; live harness delivery is a separate check.
546
+ function hookInstallCodex(options = [], host = 'codex') {
547
+ if (options.some(option => option !== '--context')) die('usage: omit hook install codex [--context]')
548
+ const dir = host === 'claude' ? '.claude' : '.codex'
549
+ const hooksPath = join(dir, host === 'claude' ? 'settings.json' : 'hooks.json')
550
+ mkdirSync(dir, { recursive: true })
551
+ const doc = loadHooksDoc(hooksPath)
544
552
 
545
553
  const scriptCmd = (script) => `node "${join(pkgRoot, 'hooks', script)}"`
546
554
  const mergeHook = (event, matcher, script) => {
@@ -556,16 +564,22 @@ function hookInstallCodex() {
556
564
 
557
565
  mergeHook('PreToolUse', 'Bash', 'command-sentinel.mjs')
558
566
  mergeHook('PreToolUse', 'Bash', 'leak-sentinel.mjs')
567
+ mergeHook('PostToolUse', 'Bash', 'dep-sentinel.mjs')
568
+ mergeHook('PostToolUse', 'Bash', 'hazard-sentinel.mjs')
559
569
  mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'dep-sentinel.mjs')
560
570
  mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'hazard-sentinel.mjs')
561
571
  mergeHook('PostToolUse', 'apply_patch|Edit|Write', 'lint-sentinel.mjs')
562
572
  mergeHook('Stop', '', 'final-draft-gate.mjs')
573
+ if (options.includes('--context')) {
574
+ mergeHook('PreToolUse', 'Bash', 'context-sentinel.mjs')
575
+ mergeHook('PostToolUse', 'Bash', 'context-sentinel.mjs')
576
+ }
563
577
 
564
578
  writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
565
579
  console.log(`wrote ${hooksPath}`)
566
580
  console.log(' verified against Codex\'s documented schema: command sentinel + leak sentinel run on Bash commands (same tool_input.command shape as Claude Code)')
567
- console.log(' best-effort, unverified: dep/hazard/lint sentinels + Final Draft gate on apply_patch/Edit/Write — they no-op safely if the field shape differs, report back if you see them miss real edits')
568
- console.log('\nCodex requires trusting new hook definitions once per session: run `/hooks` in Codex to review, or start with --dangerously-bypass-hook-trust for unattended runs.')
581
+ console.log(' file sentinels cover apply_patch/Edit/Write; dependency and hazard checks also cover Bash edits. Payload tests do not establish live Codex hook delivery.')
582
+ console.log(`\nReview the installed hooks in ${host}; installation does not prove live delivery.`)
569
583
  }
570
584
 
571
585
  // The gate goes in the repository's own hooks directory — always, never into
@@ -661,6 +675,42 @@ function verify() {
661
675
  // on purpose: a verdict rendered from a diff that could not be read is the
662
676
  // failure this whole contract exists to prevent, and a gate that cannot read the
663
677
  // change has to block it rather than pass it.
678
+ // Ribhu's hooks.json is not Codex's. Each event holds a flat list of
679
+ // { matcher, command }, the matcher is a regex over Ribhu's own lowercase tool
680
+ // names, and the payload a command receives is shaped differently, so every
681
+ // entry runs a sentinel through hooks/ribhu-adapter.mjs. Ribhu's code mode
682
+ // sends each nested tool call through these same hooks, so a script's writes
683
+ // are checked like direct ones.
684
+ function hookInstallRibhu() {
685
+ const dir = '.ribhu'
686
+ const hooksPath = join(dir, 'hooks.json')
687
+ mkdirSync(dir, { recursive: true })
688
+ const doc = loadHooksDoc(hooksPath)
689
+
690
+ const add = (event, matcher, sentinel, extra) => {
691
+ const command = `node "${join(pkgRoot, 'hooks', 'ribhu-adapter.mjs')}" ${sentinel}`
692
+ doc.hooks[event] ??= []
693
+ if (!doc.hooks[event].some((h) => h?.command === command)) doc.hooks[event].push({ ...(matcher && { matcher }), command, ...extra })
694
+ }
695
+ // Anchored: Ribhu matches with an unanchored regex, and a bare `write`
696
+ // would also catch todo_write.
697
+ const files = 'edit|multi_edit|ast_edit|write'
698
+ add('PreToolUse', '^bash$', 'command-sentinel')
699
+ add('PreToolUse', '^bash$', 'leak-sentinel')
700
+ add('PostToolUse', `^(bash|${files})$`, 'dep-sentinel')
701
+ add('PostToolUse', `^(bash|${files})$`, 'hazard-sentinel')
702
+ // Ribhu gives a hook ten seconds unless told otherwise, and a linter on a
703
+ // large repo takes longer; sixty is the most it allows.
704
+ add('PostToolUse', `^(${files})$`, 'lint-sentinel', { timeoutMs: 60000 })
705
+ add('Stop', undefined, 'final-draft-gate')
706
+
707
+ writeFileSync(hooksPath, JSON.stringify(doc, null, 2) + '\n')
708
+ console.log(`wrote ${hooksPath}`)
709
+ console.log(' command + leak sentinels before bash; dependency + hazard sentinels after bash and file edits; lint after file edits; Final Draft gate on Stop')
710
+ console.log(" checked against Ribhu's hook contract with real payload shapes; not yet verified firing inside a live Ribhu session")
711
+ console.log('\nRibhu runs a repository\'s hooks only once the project is trusted.')
712
+ }
713
+
664
714
  // ---------- codemode ----------
665
715
  // The sandbox is an optional peer, loaded here and nowhere else: every other
666
716
  // command has to keep working on a machine that never installed it.
@@ -697,16 +747,28 @@ try {
697
747
  else if (cmd === 'leak') leak(rest)
698
748
  else if (cmd === 'verify') verify()
699
749
  else if (cmd === 'codemode') await codemode(rest)
700
- else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'codex') hookInstallCodex()
750
+ else if (cmd === 'context') {
751
+ if (rest.length) die('usage: omit context < hook-payload.json')
752
+ await import('../hooks/context-sentinel.mjs')
753
+ }
754
+ else if (cmd === 'doctor') {
755
+ if (rest.some(arg => arg !== '--json')) die('usage: omit doctor [--json]')
756
+ const report = doctor(cwd)
757
+ console.log(rest.includes('--json') ? JSON.stringify(report, null, 2) : ['omit doctor (read-only; live delivery unverified)', ...report.findings.map(f => `${f.level}: ${f.message}`)].join('\n'))
758
+ if (report.findings.some(f => f.level === 'error')) process.exitCode = 1
759
+ }
760
+ else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'codex') hookInstallCodex(rest.slice(2))
761
+ else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'claude') hookInstallCodex(rest.slice(2), 'claude')
762
+ else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === 'ribhu') hookInstallRibhu()
701
763
  else if (cmd === 'hook' && rest[0] === 'install' && rest[1] === undefined) hookInstall()
702
764
  else if (cmd === 'hook' && rest[0] === 'install') {
703
- console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex]`)
765
+ console.error(`omit: unrecognized 'hook install' target '${rest[1]}' — usage: omit hook install [codex|claude|ribhu]`)
704
766
  process.exit(1)
705
767
  }
706
768
  else if (cmd === 'init') init(rest[0])
707
769
  else if (targets[cmd]) init(cmd) // back-compat: `omit cursor`
708
770
  else {
709
- console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|codemode|hook install|hook install codex>')
771
+ console.error('usage: omit <init|audit|check|gate|lint|guard|leak|verify|codemode|context|doctor [--json]|hook install [codex|claude|ribhu] [--context]>')
710
772
  process.exit(cmd ? 1 : 0)
711
773
  }
712
774
  } catch (e) {
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // Opt-in context controls. Structured tool results are never replaced.
3
+ import { readFileSync } from 'node:fs'
4
+ import { digest, readWarnings, preview, existingSessionDir, archiveText, repeatCount } from '../lib/context.mjs'
5
+
6
+ if (process.env.OMIT_OFF === '1') process.exit(0)
7
+ try {
8
+ const data = JSON.parse(readFileSync(0, 'utf8'))
9
+ if (!['Bash', 'exec_command', 'shell'].includes(data.tool_name)) process.exit(0)
10
+ const command = data.tool_input?.command ?? data.tool_input?.cmd
11
+ if (data.hook_event_name === 'PreToolUse') {
12
+ const warnings = readWarnings(command)
13
+ if (warnings.length) console.log(JSON.stringify({ systemMessage: `omit: ${warnings.join(' ')}` }))
14
+ process.exit(0)
15
+ }
16
+ if (data.hook_event_name !== 'PostToolUse' || typeof data.tool_response !== 'string') process.exit(0)
17
+ const text = data.tool_response
18
+ const limit = Number(process.env.OMIT_OUTPUT_CHARS ?? 6000)
19
+ if (!Number.isInteger(limit) || limit < 1000 || limit > 100000) throw new Error('OMIT_OUTPUT_CHARS must be 1000..100000')
20
+ if (typeof data.session_id !== 'string' || !data.session_id) throw new Error('missing session_id')
21
+ const dir = existingSessionDir(data.cwd ?? process.cwd(), data.session_id)
22
+ const count = typeof command === 'string' ? repeatCount(dir, digest(`${command}\0${text}`)) : 0
23
+ const warning = count === 3 ? 'omit: three consecutive identical commands returned identical text. Check whether another run adds information.' : ''
24
+ const shortened = preview(text, limit)
25
+ if (!shortened) {
26
+ if (warning) console.log(JSON.stringify({ systemMessage: warning }))
27
+ process.exit(0)
28
+ }
29
+ const path = archiveText(dir, text)
30
+ console.log(JSON.stringify({
31
+ decision: 'block',
32
+ reason: `${shortened.head}\n[omit: ${shortened.omitted} characters outside head/tail; full output: ${path}]\n${shortened.diagnostics ? `[selected diagnostics]\n${shortened.diagnostics}\n` : ''}${shortened.tail}`,
33
+ ...(warning ? { systemMessage: warning } : {}),
34
+ }))
35
+ } catch {
36
+ // Preserve the original result if archiving or decoding fails.
37
+ console.log(JSON.stringify({ systemMessage: 'omit: context guard could not complete; original output was not replaced.' }))
38
+ }
@@ -11,6 +11,7 @@ import { isManifest, addedDeps, MANIFESTS } from '../lib/deps.mjs'
11
11
  import { probe, fileAtRevision, repoRelPath } from '../lib/git.mjs'
12
12
  import { execDisabled, flagOn } from '../lib/exec.mjs'
13
13
  import { newDepCitations } from '../lib/receipts.mjs'
14
+ import { patchPaths } from '../lib/patch-paths.mjs'
14
15
 
15
16
  if (process.env.OMIT_OFF === '1') process.exit(0)
16
17
 
@@ -117,7 +118,8 @@ try {
117
118
  }
118
119
 
119
120
  const cwd = data.cwd ?? process.cwd()
120
- const targets =
121
+ const patched = patchPaths(data)
122
+ const targets = patched !== null ? patched.filter(isManifest) :
121
123
  typeof ti.file_path === 'string' ? (isManifest(ti.file_path) ? [ti.file_path] : []) : typeof ti.command === 'string' ? manifestsTouched(ti.command) : []
122
124
  if (targets.length === 0) process.exit(0) // an edit that cannot be a manifest edit
123
125
 
@@ -11,6 +11,7 @@ import { readFileSync, realpathSync } from 'node:fs'
11
11
  import { basename, resolve } from 'node:path'
12
12
  import { probe, git, fileAtRevision, repoRelPath } from '../lib/git.mjs'
13
13
  import { findHazards } from '../lib/hazards.mjs'
14
+ import { patchPaths } from '../lib/patch-paths.mjs'
14
15
 
15
16
  if (process.env.OMIT_OFF === '1') process.exit(0)
16
17
 
@@ -128,16 +129,19 @@ try {
128
129
 
129
130
  const file = typeof ti.file_path === 'string' ? ti.file_path : typeof ti.notebook_path === 'string' ? ti.notebook_path : null
130
131
  let findings = []
131
- if (file !== null && !/\.lock$/.test(basename(file)) && basename(file) !== 'package-lock.json') {
132
+ const files = patchPaths(data) ?? (file === null ? [] : [file])
133
+ if (files.length) {
132
134
  const root = repoRootOrNull(cwd)
133
- const abs = resolve(cwd, file)
134
- const rel = root === null ? null : relInRoot(root, abs)
135
- if (!inOmitDir(rel ?? file)) {
136
- // A notebook's added content is the cell the tool just wrote; there is no
137
- // useful diff of a .ipynb and no file_path to diff it with.
138
- findings = typeof ti.new_source === 'string' ? findHazards(ti.new_source.split('\n')) : findHazards(addedLines(abs, rel, root))
135
+ for (const file of files) {
136
+ if (/\.lock$/.test(basename(file)) || basename(file) === 'package-lock.json') continue
137
+ const abs = resolve(cwd, file)
138
+ const rel = root === null ? null : relInRoot(root, abs)
139
+ if (!inOmitDir(rel ?? file)) {
140
+ // A notebook's added content is the cell the tool just wrote.
141
+ findings.push(...(typeof ti.new_source === 'string' ? findHazards(ti.new_source.split('\n')) : findHazards(addedLines(abs, rel, root))))
142
+ }
139
143
  }
140
- } else if (typeof ti.command === 'string' && WRITE_SHAPES.test(ti.command)) {
144
+ } else if (data.tool_name !== 'apply_patch' && typeof ti.command === 'string' && WRITE_SHAPES.test(ti.command)) {
141
145
  const root = repoRootOrNull(cwd)
142
146
  // The command's own text carries what a heredoc writes. Secret rules only:
143
147
  // the injection rules are about code landing in a file, and the file check
@@ -3,6 +3,7 @@
3
3
  // objects immediately on errors. omit brings no lint rules of its own.
4
4
  import { readFileSync } from 'node:fs'
5
5
  import { lintFiles } from '../lib/lint.mjs'
6
+ import { patchPaths } from '../lib/patch-paths.mjs'
6
7
 
7
8
  if (process.env.OMIT_OFF === '1') process.exit(0)
8
9
 
@@ -31,10 +32,11 @@ try {
31
32
  )
32
33
  }
33
34
  const file = ti.file_path
34
- if (!file) process.exit(0)
35
+ const files = patchPaths(data) ?? (file ? [file] : [])
36
+ if (!files.length) process.exit(0)
35
37
 
36
38
  const cwd = data.cwd ?? process.cwd()
37
- const failing = lintFiles(cwd, [file]).filter((r) => !r.ok)
39
+ const failing = lintFiles(cwd, files).filter((r) => !r.ok)
38
40
  if (failing.length === 0) process.exit(0)
39
41
 
40
42
  console.error(
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // Ribhu's shell hooks hand a command a different payload from the one Claude
3
+ // Code and Codex do:
4
+ //
5
+ // Ribhu: { event, tool, input: { path | command, … }, cwd }
6
+ // sentinels: { tool_input: { file_path | command, … }, cwd }
7
+ //
8
+ // The exit contract is the same on both sides: 2 blocks, and stderr is the
9
+ // reason. So this translates the payload, runs the named sentinel on it, and
10
+ // hands back that sentinel's own status and reason. The sentinels are not
11
+ // touched, which means an objection raised under Ribhu is the same code as
12
+ // one raised under Claude Code, and so is every nested call a Ribhu code-mode
13
+ // script makes: those go through the same hooks as a direct call.
14
+ //
15
+ // node ribhu-adapter.mjs <sentinel> payload on stdin
16
+ //
17
+ // omitted: hook_event_name, tool_name and tool_response: no sentinel reads
18
+ // them today; translate them with the first one that does.
19
+ import { spawnSync } from 'node:child_process'
20
+ import { readFileSync } from 'node:fs'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ // load-bearing: the sentinel name comes from a hooks.json, and a cloned repo
24
+ // can carry one. Only these six files are ever run, never a path.
25
+ const SENTINELS = new Set(['command-sentinel', 'leak-sentinel', 'dep-sentinel', 'hazard-sentinel', 'lint-sentinel', 'final-draft-gate'])
26
+ const name = process.argv[2]
27
+ if (!SENTINELS.has(name)) {
28
+ console.error(`omit: the Ribhu adapter has no sentinel called "${name}". It runs: ${[...SENTINELS].join(', ')}`)
29
+ process.exit(1)
30
+ }
31
+
32
+ // A payload this cannot read is passed on as it arrived. Each sentinel already
33
+ // decides what an unreadable payload means, and that decision is not this
34
+ // file's to make differently.
35
+ const raw = readFileSync(0, 'utf8')
36
+ let payload = raw
37
+ try {
38
+ const { input, cwd } = JSON.parse(raw)
39
+ const { path, ...rest } = input !== null && typeof input === 'object' ? input : {}
40
+ payload = JSON.stringify({ cwd, tool_input: path === undefined ? rest : { ...rest, file_path: path } })
41
+ } catch {}
42
+
43
+ const run = spawnSync(process.execPath, [fileURLToPath(new URL(`./${name}.mjs`, import.meta.url))], { input: payload, encoding: 'utf8' })
44
+ if (run.error) {
45
+ console.error(`omit: could not run ${name}: ${run.error.message}`)
46
+ process.exit(1)
47
+ }
48
+ // The sentinel's stdout stops here. Ribhu reads a hook's stdout as a rewrite of
49
+ // the tool's input or output, and nothing a sentinel prints is one.
50
+ process.stderr.write(run.stderr)
51
+ process.exit(run.status ?? 1)
@@ -0,0 +1,59 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdirSync, lstatSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ export const digest = text => createHash('sha256').update(text).digest('hex')
7
+
8
+ // Advisory heuristics only: shell syntax is not parsed or rewritten here.
9
+ export function readWarnings(command) {
10
+ if (typeof command !== 'string') return []
11
+ const warnings = []
12
+ if (/\b(cat|rg|grep|find)\b/.test(command) && /(?:node_modules|\.git|coverage|\*\*|\$HOME|~\/)/.test(command)) {
13
+ warnings.push('Broad read: scope the search to source paths and exclude generated directories.')
14
+ }
15
+ if (/^\s*cat\s+[^|;>]+$/.test(command)) warnings.push('Whole-file read: prefer a targeted range or search when only part of the file is needed.')
16
+ if (/\b(?:rg|grep|find)\b.*\s\.(?:\s|$)/.test(command) && !/\|\s*(?:head|tail)\b/.test(command)) warnings.push('Repository-wide read: choose a source subdirectory or bound the returned matches.')
17
+ return warnings
18
+ }
19
+
20
+ export function preview(text, limit = 6000) {
21
+ if (text.length <= limit) return null
22
+ // Preserve some diagnostic lines even when the failure sits in the middle.
23
+ const diagnostics = (text.match(/^.*\b(?:error|failed|failure|fatal)\b.*$/gim) ?? []).join('\n').slice(0, Math.floor(limit / 5))
24
+ const remaining = limit - diagnostics.length
25
+ const head = Math.ceil(remaining / 2)
26
+ return { head: text.slice(0, head), tail: text.slice(-(remaining - head)), diagnostics, omitted: text.length - remaining }
27
+ }
28
+
29
+ // load-bearing: private, owner-controlled storage; never follow an existing symlink.
30
+ export function existingSessionDir(cwd, session) {
31
+ const path = join(tmpdir(), `omit-context-${process.getuid?.() ?? 'user'}-${digest(`${cwd}\0${session}`).slice(0, 32)}`)
32
+ try { mkdirSync(path, { mode: 0o700 }) } catch (e) {
33
+ if (e.code !== 'EEXIST') throw e
34
+ const stat = lstatSync(path)
35
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o077)) throw new Error('unsafe context storage')
36
+ }
37
+ return path
38
+ }
39
+
40
+ export function archiveText(dir, text) {
41
+ const path = join(dir, `${randomUUID()}.txt`)
42
+ writeFileSync(path, text, { flag: 'wx', mode: 0o600 })
43
+ return path
44
+ }
45
+
46
+ export function repeatCount(dir, key) {
47
+ const path = join(dir, 'repeat.json')
48
+ let previous = {}
49
+ try {
50
+ const stat = lstatSync(path)
51
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw new Error('unsafe repeat state')
52
+ previous = JSON.parse(readFileSync(path, 'utf8'))
53
+ } catch (e) { if (e.code !== 'ENOENT') throw e }
54
+ const count = previous.key === key ? Math.min((previous.count || 0) + 1, 1000000) : 1
55
+ const temporary = join(dir, `${randomUUID()}.json`)
56
+ writeFileSync(temporary, JSON.stringify({ key, count }), { flag: 'wx', mode: 0o600 })
57
+ renameSync(temporary, path)
58
+ return count
59
+ }
package/lib/doctor.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, isAbsolute, resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { probe } from './git.mjs'
5
+
6
+ // Read-only: never execute discovered hook commands to test their health.
7
+ export function doctor(cwd, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')) {
8
+ const findings = []
9
+ const seen = new Set()
10
+ const files = [...new Set([join(codexHome, 'hooks.json'), join(cwd, '.codex', 'hooks.json'), join(cwd, '.claude', 'settings.json')])]
11
+ for (const file of files) {
12
+ if (!existsSync(file)) { findings.push({ level: 'info', file, message: 'No hooks file.' }); continue }
13
+ try {
14
+ const doc = JSON.parse(readFileSync(file, 'utf8'))
15
+ if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) throw new Error('invalid hooks object')
16
+ for (const [event, groups] of Object.entries(doc.hooks)) {
17
+ if (!Array.isArray(groups)) throw new Error('invalid hook groups')
18
+ for (const group of groups) {
19
+ if (!Array.isArray(group.hooks)) throw new Error('invalid hooks list')
20
+ if (group.matcher) {
21
+ try { new RegExp(group.matcher) } catch { findings.push({ level: 'error', file, message: `${event}: invalid matcher.` }) }
22
+ if (/exec_command|^exec$|^shell$/.test(group.matcher) && !group.matcher.includes('Bash')) findings.push({ level: 'warning', file, message: `${event}: shell matcher lacks canonical Bash; verify harness aliases.` })
23
+ }
24
+ for (const hook of group.hooks) {
25
+ if (hook.type !== 'command') { findings.push({ level: 'info', file, message: `${event}: non-command hook not inspected.` }); continue }
26
+ if (typeof hook.command !== 'string') throw new Error('missing hook command')
27
+ const key = `${event}\0${group.matcher ?? ''}\0${hook.command}`
28
+ if (seen.has(key)) findings.push({ level: 'warning', file, message: `${event}: duplicate hook registration.` })
29
+ seen.add(key)
30
+ const match = /^(?:\S*\/)?(?:node|python3?)\s+(?:"([^"$]+)"|'([^']+)'|([^\s$;|&]+))\s*$/.exec(hook.command)
31
+ if (!match) { findings.push({ level: 'info', file, message: `${event}: command shape not statically checked.` }); continue }
32
+ const script = match[1] ?? match[2] ?? match[3]
33
+ const path = isAbsolute(script) ? script : resolve(cwd, script)
34
+ findings.push({ level: existsSync(path) ? 'info' : 'error', file, message: `${event}: script ${existsSync(path) ? 'exists' : 'missing'}: ${path}` })
35
+ if (existsSync(path) && path.endsWith('.py')) {
36
+ const source = readFileSync(path, 'utf8')
37
+ const names = /^TOOL_NAMES\s*=\s*\{([^}\n]*)\}/m.exec(source)?.[1]
38
+ if (names && /['"](?:exec|exec_command|shell)['"]/.test(names) && !/['"]Bash['"]/.test(names)) {
39
+ findings.push({ level: 'warning', file, message: `${event}: literal Python TOOL_NAMES omits Bash; synthetic payload testing recommended.` })
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ } catch { findings.push({ level: 'error', file, message: 'Unreadable or malformed hooks configuration.' }) }
46
+ }
47
+ const git = probe(cwd, ['config', '--get', 'core.hooksPath'])
48
+ if (git.ok && git.out.trim()) findings.push({ level: 'warning', message: 'core.hooksPath overrides the repository .git/hooks directory.' })
49
+ return { liveDelivery: 'unverified', scope: 'global Codex and current-directory Codex/Claude hook files; selected script existence and Git routing', findings }
50
+ }
@@ -0,0 +1,28 @@
1
+ // Codex reports apply_patch as tool_input.command, not file_path.
2
+ // Return null for other tools; deletions have no surviving file to inspect.
3
+ export function patchPaths(data) {
4
+ if (data.tool_name !== 'apply_patch') return null
5
+ const patch = data.tool_input?.command
6
+ // load-bearing: malformed patch payloads must not silently pass inspection.
7
+ if (typeof patch !== 'string') throw new Error('apply_patch requires tool_input.command')
8
+ const lines = patch.trim().split(/\r?\n/)
9
+ if (lines.shift() !== '*** Begin Patch' || lines.pop() !== '*** End Patch') {
10
+ throw new Error('unrecognized apply_patch envelope')
11
+ }
12
+ const paths = new Set()
13
+ let current = null
14
+ for (const line of lines) {
15
+ const header = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line)
16
+ if (header) {
17
+ current = header[1] === 'Delete' ? null : header[2]
18
+ if (current) paths.add(current)
19
+ } else if (line.startsWith('*** Move to: ')) {
20
+ if (!current) throw new Error('patch move without a source file')
21
+ paths.delete(current)
22
+ current = line.slice('*** Move to: '.length)
23
+ if (!current) throw new Error('patch move without a destination')
24
+ paths.add(current)
25
+ }
26
+ }
27
+ return [...paths]
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sriinnu/omit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Omit needless code. Editorial discipline for AI coding agents: draft less, cite everything, cut last: enforced by hooks, a pre-commit gate, and a PR bot, whatever agent writes the code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,8 +9,7 @@
9
9
  "scripts": {
10
10
  "test": "node --test",
11
11
  "preversion": "npm test",
12
- "release": "node scripts/release.mjs",
13
- "token:sync": "node scripts/sync-token.mjs"
12
+ "release": "node scripts/release.mjs"
14
13
  },
15
14
  "files": [
16
15
  "bin",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: omit
3
- description: "Editorial discipline for AI-written code: omit needless code, cite every claim, cut after it works. Use when writing or changing code, when the user says \"omit\", \"tighten this\", \"simplest solution\", \"do less\", or complains about over-engineering, bloat, or hallucinated APIs."
3
+ description: "Editorial discipline for AI-written code and local agent guardrails. Use for omit, simplifying code, verifying hook health, or reducing excessive tool output and repeated reads."
4
4
  ---
5
5
 
6
6
  # omit
@@ -11,7 +11,27 @@ Great software is edited, not written. You are the editor, not just the author:
11
11
 
12
12
  > Draft less. Cite everything. Cut last.
13
13
 
14
- ## Modes
14
+ ## Hook health and context controls
15
+
16
+ Use the locally installed `omit` CLI, or `node bin/omit.mjs` from its source
17
+ checkout. These deterministic checks require no model, provider, API key, or
18
+ network call. `omit doctor --json` inspects configuration without executing hook
19
+ commands. Script existence and synthetic tests do not prove live hook delivery.
20
+
21
+ `omit hook install codex --context` or `omit hook install claude --context`
22
+ merges opt-in context hooks into the current project's hook file. This changes
23
+ host configuration; use it when installation is requested. `omit init skill`
24
+ copies this skill to `.agents/skills/omit/SKILL.md` without overwriting a file.
25
+
26
+ The context guard warns about broad reads and three consecutive identical
27
+ command/results. It archives long plain-text shell output privately and returns
28
+ head/tail excerpts; inspect the archive when omitted details matter. Structured
29
+ results are untouched. `OMIT_OUTPUT_CHARS` controls excerpt characters (default
30
+ 6000, range 1000–100000), not tokens. It is not a quota cap. `OMIT_OFF=1` disables
31
+ the hooks. Other hosts can invoke `omit context` with compatible JSON on stdin;
32
+ verify their output-replacement contract before enabling truncation.
33
+
34
+ ## Editorial modes
15
35
 
16
36
  - **margin**: build as asked; leave notes in the margin where something could have been omitted.
17
37
  - **redline** *(default)*: full enforcement: the Seven Omissions, the Fact-Check, the Final Draft.