tackbox 0.1.83 → 0.1.88

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.
package/README.md CHANGED
@@ -169,6 +169,10 @@ past (argparse misuse, and the per-command cases below).
169
169
  PostToolUse finding on the edited lines, a non-compiling Go
170
170
  package, or an approvals inconsistency anywhere in the worktree,
171
171
  which blocks the edit in-loop.
172
+ - **hook-protocol** - `0` whenever a decision was reached, whatever the
173
+ decision says (it rides the JSON on stdout, never the exit code); `1`
174
+ plus one stderr line when none was: unreadable stdin, or a request
175
+ whose protocol version this tackbox does not speak.
172
176
  - **escapes** - `0` whenever it runs, entries or not (an inventory,
173
177
  not a gate); `1` only for a bad `--since` rev.
174
178
 
@@ -569,34 +573,44 @@ specified in [docs/report-contracts.md](docs/report-contracts.md).
569
573
  - [Python](py/tackbox_report/README.md)
570
574
  - [Java](java/report/README.md)
571
575
 
572
- ## Agent hook (Claude Code)
573
-
574
- `tackbox hook` wires the rules into an agent's edit loop. It reads a
575
- Claude Code hook event on stdin and dispatches by `hook_event_name`:
576
-
577
- - **PostToolUse** on an Edit/Write re-lints the edited file (Go: its
578
- package). On a finding it exits 2 with the finding on stderr, so
579
- the model sees it and fixes it in-loop. Every Post event - **Bash**
580
- included - also runs the whole-tree approvals consistency check:
581
- an unapproved marker, an orphaned entry, or an unresolvable file
582
- blocks with the entry named and the fix - add the manifest line,
583
- which asks, or revert. Stateless and tree-shaped: a commit changes
584
- nothing, and the block repeats on every event until the tree is
585
- consistent. The authoritative gate stays pre-commit / CI.
586
- - **PreToolUse** asks for approval before a new `.tackbox/approvals`
587
- line or a new `.tackbox/reporters` line lands, before a positive
588
- exclusion line is added to any `.gitattributes`, and before an edit
589
- to an attribute-excluded file (see "Generated and vendored code");
590
- removing a line is free. Editing markers in code draws no Pre ask -
591
- the consistency check owns them.
576
+ ## Agent hook
577
+
578
+ The rules wire into a coding agent's edit loop through one shared core: the
579
+ approval gates, the diff-scoped lint, and the whole-tree consistency check
580
+ are the same whichever host drives them. In host-neutral terms:
581
+
582
+ - **Post-edit** re-lints the touched files (Go: their package). A finding on
583
+ the lines the edit added blocks with the finding text. Every post event -
584
+ including an opaque channel - also runs the whole-tree approvals consistency
585
+ check: an unapproved marker, an orphaned entry, or an unresolvable file blocks
586
+ with the named fix. A verified violation is always a tool error. A post event
587
+ that cannot be verified reports three facts: the mutation may already have
588
+ landed, why verification did not complete, and that the mutation must not be
589
+ repeated before `dev.py check`. OMP appends that warning to the model-facing
590
+ tool result without changing a successful tool state. Claude Code writes it
591
+ to user-visible PostToolUse stderr with exit 1, so it is not model-visible.
592
+ - **Pre-edit** asks for approval before a new `.tackbox/approvals` line or a
593
+ new `.tackbox/reporters` line lands, before a positive exclusion line is
594
+ added to any `.gitattributes`, before editing an attribute-excluded file, and
595
+ before deleting or moving the root `dev.py`; removing a gate line is free.
596
+ A known target whose content is ambiguous asks when it reaches a bypass
597
+ surface. An unclassifiable file mutation or a failed policy dependency blocks
598
+ before it can run; it is never weakened into an approval prompt.
592
599
 
593
600
  Only markers in files an engine would lint participate in the check
594
601
  (D012): a marker in a Go `testdata/` path or a non-lintable fixture
595
602
  extension (a `.java.txt`) is dead text - no entry needed, no
596
603
  question - while the `.tackbox/reporters` gate stays unconditional.
597
604
 
598
- The hook is a no-op unless the edit's `cwd` is a git repo with a
599
- `dev.py` at its root. Wire it once, globally, in
605
+ The hook is inactive only when `git rev-parse --show-toplevel` emits C-locale
606
+ stderr containing `not a git repository`, or after its discovered root has no
607
+ `dev.py`. A missing git executable, corrupt Git config, or another discovery
608
+ failure is unverified, not a no-op.
609
+
610
+ ### Claude Code
611
+
612
+ `tackbox hook` reads a Claude Code hook event on stdin and dispatches by
613
+ `hook_event_name` (`PreToolUse`, `PostToolUse`). Wire it once, globally, in
600
614
  `~/.claude/settings.json`:
601
615
 
602
616
  ```json
@@ -617,6 +631,104 @@ The hook is a no-op unless the edit's `cwd` is a git repo with a
617
631
  `uvx tackbox hook` runs the cached tackbox (no `@latest`): the hook is
618
632
  fast in-loop feedback, not the authoritative gate.
619
633
 
634
+ ### Oh My Pi
635
+
636
+ ```bash
637
+ omp plugin install tackbox
638
+ ```
639
+ OMP loads the ESM entry point `js/omp/index.mjs`, which delegates to the internal
640
+ CommonJS implementation.
641
+
642
+ That is the whole wiring: the npm package declares an extension
643
+ (`package.json#omp.extensions`) and OMP loads it. The extension subscribes to
644
+ the public `tool_call` / `tool_result` events and covers all five OMP 18.x
645
+ `edit` modes: `replace`, `patch`, `hashline`, `apply_patch`, and `sloppy`,
646
+ including multi-file edits, clipboard registers, moves, and deletes. Its
647
+ compatibility parser accepts `U+00B6PATH#TAG` headers plus sloppy `[path]`,
648
+ `U+00A7path`, and `U+00A7*path` section openers; bare `U+00A7` forms continue
649
+ the current file.
650
+
651
+ - a pre-edit ask becomes a confirmation dialog. In a headless or subagent
652
+ session, where nobody can answer, it blocks with the reason instead of
653
+ approving itself; a denied ask blocks before the tool runs.
654
+ - a verified post violation becomes a tool error carrying the findings, which
655
+ keeps the agent in-loop on them.
656
+ - an unverified pre event blocks. The child has a 20-second deadline inside
657
+ OMP's 30-second handler budget. An unverified post event carries the shared
658
+ three-fact warning, omits an `isError` override so OMP preserves the host
659
+ state, and tells the model not to repeat the mutation before `dev.py check`.
660
+ - an opaque write channel (`xd://` tool devices, archive members, SQLite rows),
661
+ every `bash` call, and every `eval` call name no file, so they run the
662
+ whole-tree approvals wall alone.
663
+ - MCP tool names are not enumerated by this extension. Their file mutations are
664
+ an explicit residual outside its pre gate and post wall; review their diff and
665
+ run `dev.py check`.
666
+ - the post adapter consumes each result-detail record independently. It falls
667
+ back to a snapshot only for that record when the record is pruned; failed
668
+ records do not widen the scope of successful landed records. OMP 18.x does
669
+ not identify a landed subset for a single aggregate error without per-file
670
+ details, so Tackbox runs its whole-tree wall, preserves the host error, and
671
+ cannot safely perform targeted lint for that residual.
672
+
673
+ The extension runs `uvx tackbox@<npm package version> hook-protocol`. A tagged
674
+ wheel is built and protocol-canary tested, published to PyPI, then a successful
675
+ release workflow automatically publishes the matching npm package from its
676
+ immutable completed-run source. If a pending npm job is canceled, rerun `publish`
677
+ from the Actions UI; there is no standalone npm redispatch.
678
+
679
+ For development against a working tree, name the command explicitly - a JSON array
680
+ of argv, never a shell string:
681
+
682
+ ```bash
683
+ TACKBOX_OMP_COMMAND='["uv","run","--directory","py","python","-m","tackbox.cli"]'
684
+ ```
685
+
686
+ The subcommand is appended by the extension, never taken from the override, so
687
+ a development command cannot answer a different protocol. There is no
688
+ `@latest` fallback: an unpinned wheel could answer a protocol version the
689
+ extension does not speak.
690
+
691
+ ### Other hosts
692
+
693
+ `tackbox hook-protocol` is the host-neutral wire - one JSON event on stdin,
694
+ one JSON decision on stdout:
695
+
696
+ ```json
697
+ {"protocol": 1, "phase": "pre", "cwd": "/repo", "tool": "edit",
698
+ "targets": [{"path": "/repo/app/svc.py", "op": "edit",
699
+ "expectedPresent": true,
700
+ "added": ["x = 2"], "removed": ["x = 1"]}],
701
+ "unknown": null}
702
+ ```
703
+
704
+ ```json
705
+ {"protocol": 1, "decision": "ask",
706
+ "reason": "approve suppression marker: app/svc.py: no-report: covered upstream"}
707
+ ```
708
+
709
+ - `cwd` is the host session's non-empty absolute working directory.
710
+ - `phase` is `pre` (before the tool runs, still refusable) or `post` (after it
711
+ landed). Pre requests omit `succeeded`; post requests require the boolean
712
+ `succeeded`, so a failed tool is not misreported as a missing landed file.
713
+ - `tool` is one of `edit`, `apply_patch`, `write`, `bash`, or `eval`. `bash` and
714
+ `eval` are target-free wall-only channels.
715
+ - a **target** is one file mutation with an absolute `path`, `op`, and
716
+ `expectedPresent`. `edit` and `write` expect the path to exist, `delete`
717
+ expects it absent, and a move reports an absent source plus a present
718
+ destination. `content` is a full replacement; otherwise `added` and
719
+ `removed` are text fragments. `content` and fragments are mutually exclusive.
720
+ `ambiguous: true` means a known target needs whole-file treatment.
721
+ - **zero targets** is the opaque channel: the whole-tree wall runs, nothing
722
+ file-scoped does. `unknown` is a non-empty reason only when no concrete
723
+ target can be named; it blocks pre and warns post.
724
+ - the wire decisions are `allow`, `ask`, `block`, and `warn`. The semantic
725
+ outcomes are inactive, allow, approval-required, violation, and unverified:
726
+ unverified maps to `block` pre and `warn` post. Hosts must make a post warning
727
+ visible without turning a successful mutation into a repeatable tool error.
728
+ - exit is `0` whenever a decision was reached, whatever it says; `1` plus one
729
+ stderr line means no decision (unreadable stdin, or a protocol version this
730
+ tackbox does not speak).
731
+
620
732
  ## Escapes inventory
621
733
 
622
734
  `tackbox escapes` prints the repo's whole bypass surface as JSON on
@@ -728,7 +840,7 @@ exit 1.
728
840
  dev.py # lint / test / e2e / check (dev-script)
729
841
  hygiene.py # dev.py lint hygiene (conflict/yaml/ws/newline)
730
842
  go.mod # Go module
731
- package.json # npm package (ESLint plugin + report helper)
843
+ package.json # npm package (ESLint plugin + OMP extension + report helper)
732
844
  eslint.config.preset.js # default config used by tackbox-eslint bin
733
845
  bin/tackbox-eslint.js # ESLint CLI wrapper with bundled preset
734
846
  bin/tackbox-mdlint.js # markdownlint wrapper with bundled preset
@@ -749,6 +861,7 @@ js/
749
861
  rules/ # 14 frontend rules
750
862
  markdownlint-rules/ # custom markdownlint rules
751
863
  report.js # browser capture helper (@sentry/browser)
864
+ omp/ # Oh My Pi extension (payload parser + hook-protocol client)
752
865
  tests/ # RuleTester + node:test
753
866
  py/
754
867
  tackbox/ # lint / hook / doctor CLI, cache, engines
package/js/README.md CHANGED
@@ -13,6 +13,14 @@ lint:
13
13
  uvx tackbox@latest lint .
14
14
  ```
15
15
 
16
+ ## Oh My Pi extension
17
+
18
+ The same npm package carries tackbox's Oh My Pi extension, so
19
+ `omp plugin install tackbox` wires the rules into an agent's edit loop
20
+ with nothing else to configure. See
21
+ [Agent hook](../README.md#agent-hook) for what it gates, how a decision
22
+ reaches the agent, and the development command override.
23
+
16
24
  ## Direct ESLint integration
17
25
 
18
26
  To wire the plugin into your own ESLint run instead, install it from
package/js/omp/hook.js ADDED
@@ -0,0 +1,213 @@
1
+ // Spawn the pinned tackbox wheel and translate its protocol response.
2
+
3
+ const { spawn } = require('node:child_process')
4
+
5
+ const PROTOCOL = 1
6
+ const SUBCOMMAND = 'hook-protocol'
7
+ const TIMEOUT_MS = 20000
8
+ const COMMAND_ENV = 'TACKBOX_OMP_COMMAND'
9
+
10
+ const ALLOW = 'allow'
11
+ const ASK = 'ask'
12
+ const BLOCK = 'block'
13
+ const UNVERIFIED = 'unverified'
14
+ const WIRE_WARN = 'warn'
15
+ const WIRE_KINDS = new Set([ALLOW, ASK, BLOCK, WIRE_WARN])
16
+ const DEFAULT_TIMERS = { set: setTimeout, clear: clearTimeout }
17
+
18
+ function request(phase, cwd, normalized) {
19
+ const event = {
20
+ protocol: PROTOCOL,
21
+ phase,
22
+ cwd,
23
+ tool: normalized.tool,
24
+ targets: normalized.targets,
25
+ unknown: normalized.unknown,
26
+ }
27
+ if (normalized.targetless !== undefined) event.targetless = normalized.targetless
28
+ if (phase === 'post') event.succeeded = normalized.succeeded
29
+ return event
30
+ }
31
+
32
+ async function decide(event, options) {
33
+ const argv = resolveArgv(options.env, options.version)
34
+ if (argv.error !== null) return unverified(argv.error)
35
+ const run = await execute(argv.value, event, options.timers || DEFAULT_TIMERS)
36
+ if (run.failure !== null) return unverified(run.failure)
37
+ if (run.code !== 0) {
38
+ const detail = firstLine(run.stderr) || `${argv.value[0]} exited with ${run.code}`
39
+ return unverified(detail)
40
+ }
41
+ const decoded = parseJson(run.stdout.trim())
42
+ if (decoded.error !== null) return unverified(`unreadable hook decision (${decoded.error})`)
43
+ return asDecision(decoded.value)
44
+ }
45
+
46
+ function resolveArgv(env, version) {
47
+ const override = env[COMMAND_ENV]
48
+ if (typeof override !== 'string' || override.trim() === '') {
49
+ return { value: ['uvx', `tackbox@${version}`, SUBCOMMAND], error: null }
50
+ }
51
+ const decoded = parseJson(override)
52
+ const argv = decoded.value
53
+ const usable =
54
+ Array.isArray(argv) &&
55
+ argv.length > 0 &&
56
+ argv.every(value => typeof value === 'string' && value.trim() !== '')
57
+ if (!usable) {
58
+ return {
59
+ value: null,
60
+ error: `${COMMAND_ENV} must be a JSON array of argv strings; the tackbox hook did not run`,
61
+ }
62
+ }
63
+ return { value: [...argv, SUBCOMMAND], error: null }
64
+ }
65
+
66
+ function execute(argv, event, timers, spawnChild = spawn) {
67
+ return new Promise(resolve => {
68
+ let child
69
+ // no-report: synchronous spawn failure resolves the protocol failure outcome.
70
+ try {
71
+ child = spawnChild(argv[0], argv.slice(1), {
72
+ cwd: event.cwd,
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ windowsHide: true,
75
+ })
76
+ } catch (error) {
77
+ resolve({ code: null, stdout: '', stderr: '', failure: `cannot run ${argv[0]}: ${message(error)}` })
78
+ return
79
+ }
80
+ if (!child.stdin || !child.stdout || !child.stderr) {
81
+ resolve({ code: null, stdout: '', stderr: '', failure: `cannot run ${argv[0]}: missing protocol stream` })
82
+ return
83
+ }
84
+ let stdout = ''
85
+ let stderr = ''
86
+ let timer = null
87
+ let settled = false
88
+ let closed = false
89
+ let closeCode = null
90
+ let stdinFinished = false
91
+ const finish = (failure, code) => {
92
+ if (settled) return
93
+ settled = true
94
+ if (timer !== null) timers.clear(timer)
95
+ resolve({ code, stdout, stderr, failure })
96
+ }
97
+ const stop = failure => {
98
+ let detail = failure
99
+ if (!child.kill()) detail += '; child had already exited'
100
+ finish(detail, null)
101
+ }
102
+ const finishClose = () => {
103
+ if (closed && stdinFinished) finish(null, closeCode)
104
+ }
105
+ timer = timers.set(() => {
106
+ stop(`the tackbox hook timed out after ${TIMEOUT_MS / 1000}s`)
107
+ }, TIMEOUT_MS)
108
+ child.stdout.setEncoding('utf8')
109
+ child.stdout.on('data', chunk => {
110
+ stdout += chunk
111
+ })
112
+ child.stdout.on('error', error => {
113
+ stop(`the tackbox hook stdout stream failed: ${message(error)}`)
114
+ })
115
+ child.stderr.setEncoding('utf8')
116
+ child.stderr.on('data', chunk => {
117
+ stderr += chunk
118
+ })
119
+ child.stderr.on('error', error => {
120
+ stop(`the tackbox hook stderr stream failed: ${message(error)}`)
121
+ })
122
+ child.on('error', error => finish(`cannot run ${argv[0]}: ${message(error)}`, null))
123
+ child.on('close', code => {
124
+ closed = true
125
+ closeCode = code
126
+ finishClose()
127
+ })
128
+ child.stdin.on('finish', () => {
129
+ stdinFinished = true
130
+ finishClose()
131
+ })
132
+ child.stdin.on('error', error => {
133
+ stop(`the tackbox hook stdin stream failed: ${message(error)}`)
134
+ })
135
+ // no-report: a synchronous stdin failure has the same unverified outcome as EPIPE.
136
+ try {
137
+ child.stdin.end(JSON.stringify(event))
138
+ } catch (error) {
139
+ stop(`the tackbox hook stdin stream failed: ${message(error)}`)
140
+ }
141
+ })
142
+ }
143
+
144
+ function asDecision(payload) {
145
+ if (!isObject(payload)) return unverified('the hook decision was not a JSON object')
146
+ if (!Number.isInteger(payload.protocol) || payload.protocol !== PROTOCOL) {
147
+ return unverified(
148
+ `the hook decision speaks protocol ${JSON.stringify(payload.protocol)}; this extension speaks ${PROTOCOL}`,
149
+ )
150
+ }
151
+ if (!WIRE_KINDS.has(payload.decision)) {
152
+ return unverified(`unknown hook decision ${JSON.stringify(payload.decision)}`)
153
+ }
154
+ if (typeof payload.reason !== 'string') {
155
+ return unverified('the hook decision carried a non-string reason')
156
+ }
157
+ if (payload.decision === ALLOW && payload.reason !== '') {
158
+ return unverified('the hook allow decision unexpectedly carried a reason')
159
+ }
160
+ if (payload.decision !== ALLOW && payload.reason.trim() === '') {
161
+ return unverified('the hook decision omitted its required reason')
162
+ }
163
+ if (payload.decision === WIRE_WARN) return unverified(payload.reason)
164
+ return { kind: payload.decision, reason: payload.reason }
165
+ }
166
+
167
+ function parseJson(text) {
168
+ // parse-skip: protocol JSON is intentionally parsed only at this boundary.
169
+ // no-report: malformed JSON becomes an explicit unverified outcome.
170
+ try {
171
+ return { value: JSON.parse(text), error: null }
172
+ } catch (error) {
173
+ return { value: null, error: message(error) }
174
+ }
175
+ }
176
+
177
+ function unverified(reason) {
178
+ const text = String(reason)
179
+ return {
180
+ kind: UNVERIFIED,
181
+ reason: text.startsWith('tackbox') ? text : `tackbox: ${text}`,
182
+ }
183
+ }
184
+
185
+ function firstLine(text) {
186
+ for (const line of String(text || '').split('\n')) {
187
+ if (line.trim() !== '') return line.trim()
188
+ }
189
+ return ''
190
+ }
191
+
192
+ function isObject(value) {
193
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
194
+ }
195
+
196
+ function message(error) {
197
+ return String((error && error.message) || error)
198
+ }
199
+
200
+ module.exports = {
201
+ ALLOW,
202
+ ASK,
203
+ BLOCK,
204
+ UNVERIFIED,
205
+ COMMAND_ENV,
206
+ TIMEOUT_MS,
207
+ decide,
208
+ execute,
209
+ request,
210
+ resolveArgv,
211
+ asDecision,
212
+ unverified,
213
+ }
@@ -0,0 +1,118 @@
1
+ // Tackbox as an OMP extension over the strict shared hook protocol.
2
+
3
+ const payload = require('./payload')
4
+ const hook = require('./hook')
5
+ const manifest = require('../../package.json')
6
+
7
+ const CONFIRM_TITLE = 'tackbox approval'
8
+ const HEADLESS_NOTE =
9
+ 'tackbox cannot ask here (no interactive session), so the call is blocked' +
10
+ ' instead of approved. Re-issue it interactively, or drop the gated line.'
11
+ const DENIED = 'tackbox: approval denied.'
12
+ const BLOCKED = 'tackbox blocked this change:'
13
+
14
+ module.exports = function tackbox(pi) {
15
+ pi.setLabel('tackbox')
16
+
17
+ pi.on('tool_call', async (event, ctx) => {
18
+ const normalized = payload.normalize(event && event.toolName, event && event.input, ctx && ctx.cwd)
19
+ if (normalized === null) return undefined
20
+ if (normalized.failure) return applyPre(hook.unverified(normalized.failure), ctx)
21
+ if (normalized.unknown === null && normalized.targets.length === 0 && normalized.targetless === 'opaque') {
22
+ return undefined
23
+ }
24
+ const decision = await hook.decide(hook.request('pre', ctx.cwd, normalized), options(ctx))
25
+ return applyPre(decision, ctx)
26
+ })
27
+
28
+ pi.on('tool_result', async (event, ctx) => {
29
+ const normalized = payload.normalizeResult(
30
+ event && event.toolName,
31
+ event && event.details,
32
+ event && event.input,
33
+ ctx && ctx.cwd,
34
+ event && event.isError,
35
+ )
36
+ if (normalized === null) return undefined
37
+ const decision = normalized.failure
38
+ ? hook.unverified(normalized.failure)
39
+ : await hook.decide(hook.request('post', ctx.cwd, normalized), options(ctx))
40
+ return applyPost(decision, event)
41
+ })
42
+ }
43
+
44
+ async function applyPre(decision, ctx) {
45
+ if (decision.kind === hook.BLOCK || decision.kind === hook.UNVERIFIED) {
46
+ return { block: true, reason: decision.reason }
47
+ }
48
+ if (decision.kind === hook.ALLOW) return undefined
49
+ if (decision.kind !== hook.ASK) {
50
+ return { block: true, reason: `tackbox: unrecognized pre decision ${String(decision.kind)}` }
51
+ }
52
+ if (!ctx || !ctx.hasUI || !ctx.ui || typeof ctx.ui.confirm !== 'function') {
53
+ return { block: true, reason: `${decision.reason}\n\n${HEADLESS_NOTE}` }
54
+ }
55
+ const approved = await ctx.ui.confirm(CONFIRM_TITLE, decision.reason)
56
+ if (approved === true) return undefined
57
+ return { block: true, reason: `${DENIED}\n${decision.reason}` }
58
+ }
59
+
60
+ function applyPost(decision, event) {
61
+ const kind = decision && decision.kind
62
+ if (kind === hook.BLOCK) {
63
+ return {
64
+ content: appended(event, `${BLOCKED}\n${decision.reason}`),
65
+ isError: true,
66
+ }
67
+ }
68
+ if (kind === hook.UNVERIFIED) {
69
+ return {
70
+ content: appended(event, unverifiedPostMessage(decision.reason)),
71
+ }
72
+ }
73
+ if (kind === hook.ALLOW) return undefined
74
+ return {
75
+ content: appended(
76
+ event,
77
+ unverifiedPostMessage(`tackbox returned an unrecognized post decision ${String(kind)}`),
78
+ ),
79
+ }
80
+ }
81
+
82
+ function unverifiedPostMessage(reason) {
83
+ const text = typeof reason === 'string' ? reason : String(reason)
84
+ if (
85
+ text.includes('The mutation may already have landed.') &&
86
+ text.includes('Do not repeat the mutation; dev.py check remains required.')
87
+ ) {
88
+ return text
89
+ }
90
+ return [
91
+ 'The mutation may already have landed.',
92
+ `Tackbox verification did not complete: ${text}`,
93
+ 'Do not repeat the mutation; dev.py check remains required.',
94
+ ].join('\n')
95
+ }
96
+
97
+ function appended(event, text) {
98
+ const content = Array.isArray(event && event.content) ? event.content : []
99
+ return [...content, { type: 'text', text }]
100
+ }
101
+
102
+ function options(ctx) {
103
+ return {
104
+ env: process.env,
105
+ version: manifest.version,
106
+ timers: managedTimers(ctx),
107
+ }
108
+ }
109
+
110
+ function managedTimers(ctx) {
111
+ if (!ctx || typeof ctx.setTimeout !== 'function' || typeof ctx.clearTimer !== 'function') {
112
+ return null
113
+ }
114
+ return {
115
+ set: (fn, ms) => ctx.setTimeout(fn, ms),
116
+ clear: handle => ctx.clearTimer(handle),
117
+ }
118
+ }
@@ -0,0 +1,3 @@
1
+ import factory from './index.js'
2
+
3
+ export default factory
@@ -0,0 +1,749 @@
1
+ // OMP public tool events reduced to strict host-neutral hook protocol targets.
2
+
3
+ const path = require('node:path')
4
+
5
+ const TAGGED = /^\[(.+)#[0-9A-Fa-f]{4}\]\s*$/
6
+ const ENVELOPE = /^\*\*\* (?:Begin|End) Patch\s*$/
7
+ const FILE_SENTINEL = /^\*\*\* (Update|Add|Delete) File:\s*(.+?)\s*$/
8
+ const MOVE_SENTINEL = /^\*\*\* Move to:\s*(.+?)\s*$/
9
+ const MV_OP = /^MV\s+(.+?)\s*$/
10
+ const CUT_OP = /^CUT\b/
11
+ const REM_OP = /^REM\s*$/
12
+ const PUT_OP = /^PUT\b/
13
+ const HUNK = /^@@/
14
+ const PILCROW_HEADER = /^\s*\u00b6+(.*)$/u
15
+ const SLOPPY_HEADER = /^\[([^\]\n]+)\]\s*$/
16
+ const SLOPPY_SECTION = /^\u00a7(\*?)(.*)$/u
17
+ const SLOPPY_OPERATION = /^\u00ab/u
18
+ const URI_TARGET = /^[a-zA-Z][a-zA-Z0-9+.-]+:\/\//
19
+ const CONTAINER_TARGET = /\.(?:tar|tar\.gz|tgz|zip|jar|war|ear|apk|sqlite|sqlite3|db|db3):/i
20
+ const MESSAGE_CLIP = 120
21
+
22
+ function normalize(toolName, input, cwd) {
23
+ const tool = checkedTool(toolName)
24
+ if (tool.failure) return tool
25
+ if (tool.value === null) return null
26
+ const root = checkedRoot(cwd)
27
+ if (root.failure) return root
28
+ if (!isObject(input)) return unknown(tool.value, 'a non-object input')
29
+ if (tool.value === 'bash' || tool.value === 'eval') {
30
+ return normalized(tool.value, [], null, undefined, 'opaque')
31
+ }
32
+ if (tool.value === 'write') return normalizeWrite(tool.value, input, root.value)
33
+ return normalizeEdit(tool.value, input, root.value)
34
+ }
35
+
36
+ function normalizeResult(toolName, details, input, cwd, isError) {
37
+ const tool = checkedTool(toolName)
38
+ if (tool.failure) return tool
39
+ if (tool.value === null) return null
40
+ const root = checkedRoot(cwd)
41
+ if (root.failure) return root
42
+ if (typeof isError !== 'boolean') {
43
+ return failure('OMP supplied a non-boolean tool-result isError flag')
44
+ }
45
+ if (tool.value === 'bash' || tool.value === 'eval') {
46
+ return normalized(tool.value, [], null, !isError, 'opaque')
47
+ }
48
+ if (tool.value === 'write' && isOpaqueWrite(input)) {
49
+ return normalized(tool.value, [], null, !isError, 'opaque')
50
+ }
51
+ if (!isObject(details)) {
52
+ if (isError) return normalized(tool.value, [], null, false, 'failed')
53
+ return failure('OMP supplied no object result details for a successful file mutation')
54
+ }
55
+ const records = detailRecords(details)
56
+ if (records.failure) return records
57
+ // Pinned single-path aggregate errors do not identify which entries landed.
58
+ if (isError && !records.perFile) return normalized(tool.value, [], null, false, 'failed')
59
+ const decoded = []
60
+ for (const record of records.value) {
61
+ const parsed = detailTargets(record, root.value, tool.value)
62
+ if (parsed.failure) return parsed
63
+ if (parsed.failed) {
64
+ if (!isError) return failure('OMP reported a failed per-file result without an aggregate error')
65
+ continue
66
+ }
67
+ decoded.push(parsed)
68
+ }
69
+ let targets = decoded.flatMap(result => result.targets)
70
+ if (decoded.some(result => result.snapshotsPruned)) {
71
+ const fallback = normalize(tool.value, input, root.value)
72
+ if (
73
+ fallback === null ||
74
+ fallback.failure ||
75
+ fallback.unknown !== null ||
76
+ fallback.targetless !== undefined
77
+ ) {
78
+ return failure('OMP pruned result snapshots and the original input cannot be matched safely')
79
+ }
80
+ const unused = new Set(fallback.targets.map((_, index) => index))
81
+ targets = []
82
+ for (const parsed of decoded) {
83
+ if (!parsed.snapshotsPruned) {
84
+ targets.push(...parsed.targets)
85
+ continue
86
+ }
87
+ const merged = mergePrunedTargets(parsed.targets, fallback.targets, unused)
88
+ if (merged.failure) return merged
89
+ targets.push(...merged.value)
90
+ }
91
+ if (!records.perFile && unused.size > 0) {
92
+ return failure('OMP result details omitted one or more mutated input targets')
93
+ }
94
+ }
95
+ if (targets.length === 0) {
96
+ const targetless = isError
97
+ ? 'failed'
98
+ : decoded.every(result => result.targetless === 'no-op')
99
+ ? 'no-op'
100
+ : null
101
+ if (targetless === null) return failure('OMP result details name no landed target')
102
+ return normalized(tool.value, [], null, !isError, targetless)
103
+ }
104
+ return normalized(tool.value, targets, null, !isError)
105
+ }
106
+
107
+ function checkedTool(toolName) {
108
+ if (typeof toolName !== 'string' || toolName.trim() === '') {
109
+ return failure('OMP supplied a malformed toolName')
110
+ }
111
+ if (!['edit', 'apply_patch', 'write', 'bash', 'eval'].includes(toolName)) {
112
+ return { value: null }
113
+ }
114
+ return { value: toolName }
115
+ }
116
+
117
+ function checkedRoot(cwd) {
118
+ if (typeof cwd !== 'string' || cwd === '' || !path.isAbsolute(cwd)) {
119
+ return failure('OMP supplied no absolute session cwd')
120
+ }
121
+ return { value: cwd }
122
+ }
123
+
124
+ function sessionRoot(cwd) {
125
+ const root = checkedRoot(cwd)
126
+ return root.failure ? null : root.value
127
+ }
128
+
129
+ function normalizeWrite(tool, input, cwd) {
130
+ const raw = untag(stringOrNull(input.path))
131
+ if (raw === null) return unknown(tool, 'a write with no string path')
132
+ if (isOpaquePath(raw)) return normalized(tool, [], null, undefined, 'opaque')
133
+ const content = stringOrNull(input.content)
134
+ if (content === null) return unknown(tool, `a write to ${clip(raw)} with no string content`)
135
+ return normalized(tool, [target(path.resolve(cwd, raw), 'write', true, { content })], null)
136
+ }
137
+
138
+ function normalizeEdit(tool, input, cwd) {
139
+ const patchInput = stringOrNull(input.input) || stringOrNull(input._input)
140
+ if (patchInput !== null) return parsePatchText(tool, patchInput, cwd)
141
+ if (typeof input.old_string === 'string' || typeof input.new_string === 'string') {
142
+ return normalizeReplace(tool, input, cwd)
143
+ }
144
+ if (Array.isArray(input.edits)) return normalizeEdits(tool, input, cwd)
145
+ return normalizePublicPaths(tool, input, cwd)
146
+ }
147
+ function normalizePublicPaths(tool, input, cwd) {
148
+ if (input.paths !== undefined) {
149
+ if (!Array.isArray(input.paths) || input.paths.length === 0) {
150
+ return unknown(tool, 'an OMP paths compatibility field that is not a non-empty array')
151
+ }
152
+ const paths = []
153
+ const seen = new Set()
154
+ for (const value of input.paths) {
155
+ const raw = untag(stringOrNull(value))
156
+ if (raw === null) return unknown(tool, 'an OMP paths compatibility field with a non-string path')
157
+ if (!seen.has(raw)) {
158
+ seen.add(raw)
159
+ paths.push(raw)
160
+ }
161
+ }
162
+ const direct = untag(stringOrNull(input.path))
163
+ if (direct !== null && !seen.has(direct)) {
164
+ return unknown(tool, 'conflicting OMP path and paths compatibility fields')
165
+ }
166
+ return normalized(
167
+ tool,
168
+ paths.map(raw => target(path.resolve(cwd, raw), 'edit', true, { ambiguous: true })),
169
+ null,
170
+ )
171
+ }
172
+ const raw = editPath(input)
173
+ if (raw === null) {
174
+ return unknown(tool, `an ${tool} payload with no input, old_string/new_string, edits, path, or paths field`)
175
+ }
176
+ return normalized(tool, [target(path.resolve(cwd, raw), 'edit', true, { ambiguous: true })], null)
177
+ }
178
+
179
+
180
+ function normalizeReplace(tool, input, cwd) {
181
+ const raw = editPath(input)
182
+ if (raw === null) return unknown(tool, 'a replace edit with no string path')
183
+ return normalized(tool, [
184
+ target(path.resolve(cwd, raw), 'edit', true, {
185
+ added: [typeof input.new_string === 'string' ? input.new_string : ''],
186
+ removed: [typeof input.old_string === 'string' ? input.old_string : ''],
187
+ }),
188
+ ], null)
189
+ }
190
+
191
+ function normalizeEdits(tool, input, cwd) {
192
+ const raw = editPath(input)
193
+ if (raw === null) return unknown(tool, 'a patch payload with no string path')
194
+ if (input.edits.length === 0) return unknown(tool, 'a patch payload with an empty edits list')
195
+ const drafts = new Map()
196
+ const source = draftFor(drafts, path.resolve(cwd, raw))
197
+ for (const entry of input.edits) {
198
+ if (!isObject(entry)) return unknown(tool, 'a patch edit that is not an object')
199
+ const op = entry.op === undefined ? 'update' : entry.op
200
+ if (typeof op !== 'string') return unknown(tool, 'a patch edit with a non-string op')
201
+ if (entry.rename !== undefined) {
202
+ const renamed = stringOrNull(entry.rename)
203
+ if (renamed === null) source.ambiguous = true
204
+ else markMove(source, draftFor(drafts, path.resolve(cwd, renamed)))
205
+ }
206
+ const destination = source.operation === 'move' ? moveDestination(drafts, source) : source
207
+ if (op === 'delete') {
208
+ markDelete(source)
209
+ continue
210
+ }
211
+ if (op === 'create') {
212
+ destination.operation = 'write'
213
+ destination.expectedPresent = true
214
+ if (
215
+ input.edits.length === 1 &&
216
+ entry.rename === undefined &&
217
+ typeof entry.diff === 'string' &&
218
+ entry.diff.length > 0
219
+ ) {
220
+ setContent(destination, entry.diff)
221
+ } else {
222
+ destination.ambiguous = true
223
+ }
224
+ continue
225
+ }
226
+ if (op !== 'update' || typeof entry.diff !== 'string' || entry.diff.length === 0) {
227
+ destination.ambiguous = true
228
+ continue
229
+ }
230
+ appendPatchFragments(destination, entry.diff)
231
+ }
232
+ return normalized(tool, finalizeDrafts(drafts), null)
233
+ }
234
+
235
+ function parsePatchText(tool, text, cwd) {
236
+ const drafts = new Map()
237
+ let current = null
238
+ let run = []
239
+ const flush = () => {
240
+ if (run.length > 0 && current !== null) current.added.push(run.join('\n'))
241
+ run = []
242
+ }
243
+ const open = (raw, operation = 'edit') => {
244
+ flush()
245
+ current = draftFor(drafts, path.resolve(cwd, unquote(raw)))
246
+ if (operation === 'write') current.operation = 'write'
247
+ if (operation === 'delete') markDelete(current)
248
+ }
249
+ const lines = text.split('\n')
250
+ for (let index = 0; index < lines.length; index++) {
251
+ const rawLine = lines[index]
252
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
253
+ if (line.startsWith('+')) {
254
+ if (current === null) return unknown(tool, `an added row before any file section: ${clip(line)}`)
255
+ run.push(line.slice(1))
256
+ continue
257
+ }
258
+ flush()
259
+ if (line.trim() === '' || ENVELOPE.test(line) || HUNK.test(line)) continue
260
+ const tagged = TAGGED.exec(line)
261
+ if (tagged !== null) {
262
+ open(tagged[1])
263
+ continue
264
+ }
265
+ const pilcrow = PILCROW_HEADER.exec(line)
266
+ if (pilcrow !== null) {
267
+ const compatPath = pilcrowPath(pilcrow[1])
268
+ if (compatPath === null) return unknown(tool, 'a paragraph-sign header with no path')
269
+ open(compatPath)
270
+ continue
271
+ }
272
+ const sloppy = SLOPPY_SECTION.exec(line)
273
+ if (sloppy !== null) {
274
+ const sloppyPath = sloppy[2].trim()
275
+ if (sloppyPath !== '') {
276
+ open(sloppyPath)
277
+ } else if (current === null) {
278
+ return unknown(tool, 'a sloppy operation before any file section')
279
+ }
280
+ current.ambiguous = true
281
+ continue
282
+ }
283
+ const sloppyHeader = SLOPPY_HEADER.exec(line)
284
+ if (
285
+ sloppyHeader !== null &&
286
+ (current === null || startsSloppyOperation(lines, index + 1))
287
+ ) {
288
+ const sloppyPath = sloppyHeader[1].trim()
289
+ if (sloppyPath === '') return unknown(tool, 'a sloppy [path] header with no path')
290
+ open(sloppyPath)
291
+ continue
292
+ }
293
+ const sentinel = FILE_SENTINEL.exec(line)
294
+ if (sentinel !== null) {
295
+ open(sentinel[2], sentinel[1] === 'Add' ? 'write' : sentinel[1] === 'Delete' ? 'delete' : 'edit')
296
+ continue
297
+ }
298
+ const move = MV_OP.exec(line) || MOVE_SENTINEL.exec(line)
299
+ if (move !== null) {
300
+ if (current === null) return unknown(tool, 'a move operation before any file section')
301
+ const destination = draftFor(drafts, path.resolve(cwd, unquote(move[1])))
302
+ markMove(current, destination)
303
+ current = destination
304
+ continue
305
+ }
306
+ if (line.startsWith('-') || line.startsWith(' ')) {
307
+ if (current === null) return unknown(tool, `a patch row before any file section: ${clip(line)}`)
308
+ continue
309
+ }
310
+ if (REM_OP.test(line)) {
311
+ if (current === null) return unknown(tool, 'a remove operation before any file section')
312
+ markDelete(current)
313
+ continue
314
+ }
315
+ if (CUT_OP.test(line)) {
316
+ if (current === null) return unknown(tool, 'a cut operation before any file section')
317
+ continue
318
+ }
319
+ if (PUT_OP.test(line)) {
320
+ if (current === null) return unknown(tool, 'a put operation before any file section')
321
+ if (!line.endsWith(':')) current.ambiguous = true
322
+ continue
323
+ }
324
+ if (current === null) return unknown(tool, `an unrecognized patch row before any file section: ${clip(line)}`)
325
+ current.ambiguous = true
326
+ }
327
+ flush()
328
+ if (drafts.size === 0) {
329
+ return unknown(tool, 'a patch payload with no [PATH#TAG], paragraph-sign, sloppy, or `*** ... File:` section')
330
+ }
331
+ return normalized(tool, finalizeDrafts(drafts), null)
332
+ }
333
+
334
+ function startsSloppyOperation(lines, from) {
335
+ for (let index = from; index < lines.length; index++) {
336
+ const trimmed = lines[index].trim()
337
+ if (trimmed === '') continue
338
+ return SLOPPY_SECTION.test(trimmed) || SLOPPY_OPERATION.test(trimmed)
339
+ }
340
+ return false
341
+ }
342
+
343
+ function pilcrowPath(value) {
344
+ const trimmed = value.trim()
345
+ if (trimmed === '') return null
346
+ const tag = /#[0-9A-Fa-f]{4}$/.exec(trimmed)
347
+ const raw = tag === null ? trimmed : trimmed.slice(0, tag.index)
348
+ const unquoted = unquote(raw)
349
+ return unquoted === '' ? null : unquoted
350
+ }
351
+
352
+ function detailRecords(details) {
353
+ if (details.perFileResults === undefined) return { value: [details], perFile: false }
354
+ if (!Array.isArray(details.perFileResults) || details.perFileResults.length === 0) {
355
+ return failure('OMP supplied malformed perFileResults')
356
+ }
357
+ const records = []
358
+ for (const result of details.perFileResults) {
359
+ if (!isObject(result)) return failure('OMP supplied a non-object perFileResults entry')
360
+ records.push({
361
+ ...result,
362
+ snapshotsPruned: result.snapshotsPruned === undefined
363
+ ? details.snapshotsPruned
364
+ : result.snapshotsPruned,
365
+ })
366
+ }
367
+ return { value: records, perFile: true }
368
+ }
369
+
370
+ function detailTargets(record, cwd, tool) {
371
+ const valid = validateDetail(record)
372
+ if (valid.failure) return valid
373
+ if (record.isError === true) {
374
+ return { targets: [], snapshotsPruned: false, targetless: null, failed: true }
375
+ }
376
+ const operation = detailOperation(record, tool)
377
+ if (operation.failure) return operation
378
+ if (tool === 'write' && operation.value !== 'write') {
379
+ return failure('OMP write result details carry a non-write operation')
380
+ }
381
+ const paths = detailPaths(record, operation.value, cwd, tool)
382
+ if (paths.failure) return paths
383
+ const targets = paths.value
384
+ const snapshotsPruned = record.snapshotsPruned === true
385
+ const destination = targets.find(item => item.expectedPresent)
386
+ if (destination !== undefined) {
387
+ if (typeof record.newText === 'string') {
388
+ delete destination.added
389
+ delete destination.removed
390
+ delete destination.ambiguous
391
+ destination.content = record.newText
392
+ } else if (!snapshotsPruned) {
393
+ destination.ambiguous = true
394
+ }
395
+ }
396
+ return {
397
+ targets,
398
+ snapshotsPruned,
399
+ targetless: paths.targetless || null,
400
+ failed: false,
401
+ }
402
+ }
403
+
404
+ function validateDetail(record) {
405
+ for (const key of ['path', 'resolvedPath', 'sourcePath', 'op', 'oldText', 'newText', 'diff', 'errorText']) {
406
+ if (record[key] !== undefined && typeof record[key] !== 'string') {
407
+ return failure(`OMP supplied a non-string result details.${key}`)
408
+ }
409
+ }
410
+ if (record.isError !== undefined && typeof record.isError !== 'boolean') {
411
+ return failure('OMP supplied a non-boolean result details.isError')
412
+ }
413
+ if (record.errorText !== undefined && record.isError !== true) {
414
+ return failure('OMP supplied errorText without a failed result record')
415
+ }
416
+ if (record.snapshotsPruned !== undefined && typeof record.snapshotsPruned !== 'boolean') {
417
+ return failure('OMP supplied a non-boolean result details.snapshotsPruned')
418
+ }
419
+ if (
420
+ record.move !== undefined &&
421
+ typeof record.move !== 'boolean' &&
422
+ typeof record.move !== 'string' &&
423
+ !isObject(record.move)
424
+ ) {
425
+ return failure('OMP supplied malformed result details.move')
426
+ }
427
+ return { value: true }
428
+ }
429
+
430
+ function detailOperation(record, tool) {
431
+ const raw = record.op
432
+ const move = hasMove(record)
433
+ if (raw === undefined) return { value: move ? 'move' : tool === 'write' ? 'write' : 'edit' }
434
+ if (['update', 'replace', 'patch', 'edit', 'apply_patch'].includes(raw)) {
435
+ return { value: move ? 'move' : 'edit' }
436
+ }
437
+ if (['create', 'write'].includes(raw)) return { value: 'write' }
438
+ if (raw === 'delete') return { value: 'delete' }
439
+ if (['move', 'rename'].includes(raw)) return { value: 'move' }
440
+ return failure(`OMP supplied an unsupported result details.op ${JSON.stringify(raw)}`)
441
+ }
442
+
443
+ function detailPaths(record, operation, cwd, tool) {
444
+ const source = detailString(record, 'sourcePath') || movePath(record.move, 'source')
445
+ const declaredPath = detailString(record, 'path')
446
+ const resolvedPath = detailString(record, 'resolvedPath')
447
+ const destination = tool === 'write'
448
+ ? resolvedPath || declaredPath || movePath(record.move, 'destination')
449
+ : declaredPath || resolvedPath || movePath(record.move, 'destination')
450
+ if (operation === 'delete') {
451
+ const deleted = source || destination
452
+ if (deleted === null) return failure('OMP delete result details name no source path')
453
+ return { value: [target(path.resolve(cwd, deleted), 'delete', false)] }
454
+ }
455
+ if (operation === 'move') {
456
+ if (source === null || destination === null) {
457
+ return failure('OMP move result details must name both sourcePath and path')
458
+ }
459
+ const sourcePath = path.resolve(cwd, source)
460
+ const destinationPath = path.resolve(cwd, destination)
461
+ const pair = moveId(sourcePath, destinationPath)
462
+ return {
463
+ value: [
464
+ target(sourcePath, 'move', false, { moveId: pair }),
465
+ target(destinationPath, 'move', true, { ambiguous: true, moveId: pair }),
466
+ ],
467
+ }
468
+ }
469
+ const landed = destination || source
470
+ if (landed === null && operation === 'edit') {
471
+ if (isExactNoop(record)) return { value: [], targetless: 'no-op' }
472
+ if (hasPathlessMutation(record)) {
473
+ return failure('OMP supplied mutating pathless edit result details')
474
+ }
475
+ return failure('OMP supplied malformed pathless edit result details')
476
+ }
477
+ if (landed === null) return failure('OMP result details name no landed path')
478
+ return { value: [target(path.resolve(cwd, landed), operation, true, { ambiguous: true })] }
479
+ }
480
+
481
+ function isExactNoop(record) {
482
+ return (
483
+ record.op === 'update' &&
484
+ record.diff === '' &&
485
+ record.oldText === undefined &&
486
+ record.newText === undefined &&
487
+ record.snapshotsPruned !== true &&
488
+ !hasMove(record)
489
+ )
490
+ }
491
+
492
+ function hasPathlessMutation(record) {
493
+ return ['diff', 'oldText', 'newText'].some(
494
+ key => typeof record[key] === 'string' && record[key] !== '',
495
+ )
496
+ }
497
+
498
+ function mergePrunedTargets(actual, intended, unused) {
499
+ const merged = []
500
+ for (const targetFromResult of actual) {
501
+ const match = [...unused].find(index => sameTarget(targetFromResult, intended[index]))
502
+ if (match === undefined) {
503
+ return failure(`OMP pruned snapshots for ${targetFromResult.path}, which input did not match`)
504
+ }
505
+ unused.delete(match)
506
+ const intent = intended[match]
507
+ const result = {
508
+ path: targetFromResult.path,
509
+ op: targetFromResult.op,
510
+ expectedPresent: targetFromResult.expectedPresent,
511
+ }
512
+ if (targetFromResult.moveId !== undefined) result.moveId = targetFromResult.moveId
513
+ if (carriesLandedContent(result)) {
514
+ if (intent.content !== undefined) result.content = intent.content
515
+ else {
516
+ result.added = intent.added || []
517
+ result.removed = intent.removed || []
518
+ if (intent.ambiguous) result.ambiguous = true
519
+ }
520
+ }
521
+ merged.push(result)
522
+ }
523
+ return { value: merged }
524
+ }
525
+ function carriesLandedContent(value) {
526
+ return value.expectedPresent && value.op !== 'delete'
527
+ }
528
+
529
+ function sameTarget(left, right) {
530
+ return left.path === right.path &&
531
+ left.op === right.op &&
532
+ left.expectedPresent === right.expectedPresent
533
+ }
534
+
535
+ function moveId(source, destination) {
536
+ return JSON.stringify([source, destination])
537
+ }
538
+
539
+
540
+ function hasMove(record) {
541
+ return record.move === true || typeof record.move === 'string' || isObject(record.move)
542
+ }
543
+
544
+ function detailString(record, key) {
545
+ return typeof record[key] === 'string' && record[key].trim() !== '' ? record[key] : null
546
+ }
547
+
548
+ function movePath(move, side) {
549
+ if (typeof move === 'string') return side === 'destination' ? move : null
550
+ if (!isObject(move)) return null
551
+ const keys = side === 'source' ? ['sourcePath', 'source', 'from'] : ['path', 'destination', 'to']
552
+ for (const key of keys) {
553
+ if (typeof move[key] === 'string' && move[key].trim() !== '') return move[key]
554
+ }
555
+ return null
556
+ }
557
+
558
+ function target(file, operation, expectedPresent, fields = {}) {
559
+ return { path: file, op: operation, expectedPresent, ...fields }
560
+ }
561
+
562
+ function draftFor(drafts, file) {
563
+ const existing = drafts.get(file)
564
+ if (existing !== undefined) return existing
565
+ const draft = {
566
+ path: file,
567
+ operation: 'edit',
568
+ expectedPresent: true,
569
+ added: [],
570
+ removed: [],
571
+ ambiguous: false,
572
+ moveDestination: null,
573
+ moveId: null,
574
+ }
575
+ drafts.set(file, draft)
576
+ return draft
577
+ }
578
+
579
+ function markDelete(draft) {
580
+ draft.operation = 'delete'
581
+ draft.expectedPresent = false
582
+ draft.added = []
583
+ draft.content = undefined
584
+ draft.ambiguous = false
585
+ draft.moveDestination = null
586
+ draft.moveId = null
587
+ }
588
+
589
+ function markMove(source, destination) {
590
+ const pair = moveId(source.path, destination.path)
591
+ source.operation = 'move'
592
+ source.expectedPresent = false
593
+ source.added = []
594
+ source.content = undefined
595
+ source.ambiguous = false
596
+ source.moveDestination = destination.path
597
+ source.moveId = pair
598
+ destination.operation = 'move'
599
+ destination.expectedPresent = true
600
+ destination.ambiguous = true
601
+ destination.moveId = pair
602
+ }
603
+
604
+ function moveDestination(drafts, source) {
605
+ return source.moveDestination === null ? source : draftFor(drafts, source.moveDestination)
606
+ }
607
+
608
+ function setContent(draft, content) {
609
+ draft.content = content.endsWith('\n') ? content : `${content}\n`
610
+ draft.added = []
611
+ draft.removed = []
612
+ draft.ambiguous = false
613
+ }
614
+
615
+
616
+ function finalizeDrafts(drafts) {
617
+ return [...drafts.values()].map(finalizeDraft)
618
+ }
619
+
620
+ function finalizeDraft(draft) {
621
+ const value = target(draft.path, draft.operation, draft.expectedPresent)
622
+ if (draft.operation === 'move' && draft.moveId !== null) value.moveId = draft.moveId
623
+ if (draft.content !== undefined) {
624
+ value.content = draft.content
625
+ return value
626
+ }
627
+ if (draft.operation !== 'delete' && !(draft.operation === 'move' && !draft.expectedPresent)) {
628
+ value.added = draft.added
629
+ value.removed = draft.removed
630
+ }
631
+ if (draft.ambiguous) value.ambiguous = true
632
+ return value
633
+ }
634
+
635
+ function appendPatchFragments(targetDraft, diff) {
636
+ let inHunk = false
637
+ let added = []
638
+ let removed = []
639
+ const flush = () => {
640
+ if (added.length > 0) targetDraft.added.push(added.join('\n'))
641
+ if (removed.length > 0) targetDraft.removed.push(removed.join('\n'))
642
+ added = []
643
+ removed = []
644
+ }
645
+ const lines = diff.split('\n')
646
+ for (let index = 0; index < lines.length; index += 1) {
647
+ const raw = lines[index]
648
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
649
+ if (line.startsWith('@@')) {
650
+ flush()
651
+ inHunk = true
652
+ continue
653
+ }
654
+ if (line === '' && index === lines.length - 1) continue
655
+ if (!inHunk) {
656
+ targetDraft.ambiguous = true
657
+ continue
658
+ }
659
+ if (line.startsWith('+')) {
660
+ if (removed.length > 0) {
661
+ targetDraft.removed.push(removed.join('\n'))
662
+ removed = []
663
+ }
664
+ added.push(line.slice(1))
665
+ continue
666
+ }
667
+ if (line.startsWith('-')) {
668
+ if (added.length > 0) {
669
+ targetDraft.added.push(added.join('\n'))
670
+ added = []
671
+ }
672
+ removed.push(line.slice(1))
673
+ continue
674
+ }
675
+ if (line.startsWith(' ')) {
676
+ flush()
677
+ continue
678
+ }
679
+ targetDraft.ambiguous = true
680
+ }
681
+ flush()
682
+ if (!inHunk) targetDraft.ambiguous = true
683
+ }
684
+
685
+
686
+ function editPath(input) {
687
+ return stringOrNull(input.path) || stringOrNull(input._path) || stringOrNull(input.file_path)
688
+ }
689
+
690
+ function stringOrNull(value) {
691
+ if (typeof value !== 'string' || value.trim() === '') return null
692
+ return value
693
+ }
694
+
695
+ function untag(value) {
696
+ if (value === null) return null
697
+ const tagged = TAGGED.exec(value)
698
+ return tagged === null ? value : tagged[1].trim()
699
+ }
700
+
701
+ function unquote(value) {
702
+ const trimmed = value.trim()
703
+ if (
704
+ trimmed.length >= 2 &&
705
+ ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
706
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
707
+ ) {
708
+ return trimmed.slice(1, -1)
709
+ }
710
+ return trimmed
711
+ }
712
+
713
+ function normalized(tool, targets, unknown, succeeded, targetless) {
714
+ const value = { tool, targets, unknown }
715
+ if (succeeded !== undefined) value.succeeded = succeeded
716
+ if (targetless !== undefined) value.targetless = targetless
717
+ return value
718
+ }
719
+
720
+ function unknown(tool, detail) {
721
+ return normalized(
722
+ tool,
723
+ [],
724
+ `tackbox cannot classify this ${tool} call (${detail}). Re-issue it in a documented form; dev.py check remains required.`,
725
+ )
726
+ }
727
+
728
+ function failure(reason) {
729
+ return { failure: `tackbox cannot verify this hook event: ${reason}` }
730
+ }
731
+
732
+ function isOpaqueWrite(input) {
733
+ return isObject(input) && isOpaquePath(untag(stringOrNull(input.path)))
734
+ }
735
+
736
+ function isOpaquePath(value) {
737
+ return value !== null && (URI_TARGET.test(value) || CONTAINER_TARGET.test(value))
738
+ }
739
+
740
+ function isObject(value) {
741
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
742
+ }
743
+
744
+ function clip(text) {
745
+ const flat = text.replace(/\s+/g, ' ').trim()
746
+ return flat.length > MESSAGE_CLIP ? `${flat.slice(0, MESSAGE_CLIP)}...` : flat
747
+ }
748
+
749
+ module.exports = { normalize, normalizeResult, sessionRoot }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tackbox",
3
- "version": "0.1.83",
4
- "description": "ESLint and Markdown lint plugins plus direct error-reporting helpers for JavaScript and TypeScript.",
3
+ "version": "0.1.88",
4
+ "description": "ESLint and Markdown lint plugins, an Oh My Pi extension, plus direct error-reporting helpers for JavaScript and TypeScript.",
5
5
  "license": "MIT",
6
6
  "main": "./js/eslint-plugin.js",
7
7
  "exports": {
@@ -12,11 +12,20 @@
12
12
  "tackbox-eslint": "./bin/tackbox-eslint.js",
13
13
  "tackbox-mdlint": "./bin/tackbox-mdlint.js"
14
14
  },
15
+ "omp": {
16
+ "extensions": [
17
+ "./js/omp/index.mjs"
18
+ ]
19
+ },
15
20
  "files": [
16
21
  "js/eslint-plugin.js",
17
22
  "js/report.js",
18
23
  "js/rules/",
19
24
  "js/markdownlint-rules/",
25
+ "js/omp/index.mjs",
26
+ "js/omp/index.js",
27
+ "js/omp/hook.js",
28
+ "js/omp/payload.js",
20
29
  "js/README.md",
21
30
  "bin/",
22
31
  "eslint.config.preset.js"