@ngockhoale/ukit 2.1.5 → 2.2.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +7 -4
  3. package/manifests/platform.full.yaml +121 -24
  4. package/package.json +4 -3
  5. package/src/cli/adapters.js +47 -21
  6. package/src/cli/index.js +2 -2
  7. package/src/core/applyPlan.js +5 -2
  8. package/src/core/ensureGitignore.js +2 -0
  9. package/src/core/runInstallPipeline.js +19 -0
  10. package/src/core/runtimeConfig.js +6 -1
  11. package/src/core/status.js +3 -1
  12. package/src/core/uninstall.js +16 -0
  13. package/src/index/routeCatalog.js +1 -1
  14. package/src/manifest/selectItems.js +11 -5
  15. package/templates/.claude/commands/ukit/handoff-clear.md +1 -1
  16. package/templates/.claude/commands/ukit/handoff-create.md +10 -8
  17. package/templates/.claude/commands/ukit/handoff-fullstack.md +22 -18
  18. package/templates/.claude/commands/ukit/handoff-implement.md +6 -4
  19. package/templates/.claude/commands/ukit/handoff-review.md +5 -3
  20. package/templates/.claude/commands/ukit/handoff-status.md +1 -1
  21. package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
  22. package/templates/.claude/ukit/index/unic-gateway.mjs +43 -7
  23. package/templates/.codex/README.md +1 -1
  24. package/templates/.gitignore +2 -0
  25. package/templates/.omp/AGENTS.md +9 -0
  26. package/templates/.omp/README.md +96 -0
  27. package/templates/.omp/RULES.md +62 -0
  28. package/templates/.omp/agents/bug-debugger.md +85 -0
  29. package/templates/.omp/agents/code-reviewer.md +197 -0
  30. package/templates/.omp/agents/feature-implementer.md +123 -0
  31. package/templates/.omp/agents/handoff-planner.md +210 -0
  32. package/templates/.omp/agents/ukit-small-task-maintainer.md +72 -0
  33. package/templates/.omp/agents/ukit-vision-analyst.md +100 -0
  34. package/templates/.omp/config.yml +90 -0
  35. package/templates/.omp/hooks/pre/ukit-bridge.js +368 -0
  36. package/templates/AGENTS.md +132 -64
  37. package/templates/CLAUDE.md +59 -21
  38. package/templates/docs/PROJECT.md +1 -1
  39. package/templates/ukit/storage/config.json +10 -0
  40. package/templates/adapter-presets/antigravity/README.md +0 -22
  41. package/templates/adapter-presets/antigravity/rules.md +0 -49
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: ukit-small-task-maintainer
3
+ description: "Internal UKit maintenance subagent for low-risk, reversible UKit decisions. Use proactively when UKit needs to decide or perform safe cleanup such as pruning docs/TASKS.md, classifying queued work, choosing whether compact/summarization is appropriate, summarizing local docs/status, or maintaining small UKit runtime queues. Do not use for product implementation, security, release/publish, data-loss, architecture, or risky/shared code changes."
4
+ model: "@lite"
5
+ tools: ["read","grep","glob","edit","write","ast_edit"]
6
+ blocking: false
7
+ ---
8
+
9
+ You are UKit's internal small-task maintainer. You run as a sidecar/parallel/non-blocking lane for safe, reversible UKit orchestration chores so the end user can stay focused on product work and only remember `ukit install`. Runtime tuning lives in `.ukit/storage/config.json`. You must follow `docs/UKIT_CODEV_PRINCIPLES.md`: hide complexity, preserve output quality, and never trade correctness for speed or token savings.
10
+
11
+ ## Model Policy
12
+
13
+ - Use the model selected by `subagents.smallTaskModel` when the host supports model selection for this subagent.
14
+ - Default intended model: `unic-lite`.
15
+ - If the host cannot bind a model from config directly, still follow this role and report that the configured model is a hint.
16
+ - This lane must run separately from the user's main task model. It must never replace, pause, or slow down the main task.
17
+
18
+ ## Use For
19
+
20
+ - Cleaning and deduplicating `docs/TASKS.md`.
21
+ - Pruning stale local AI queue/status noise.
22
+ - Classifying queued items by readiness/risk.
23
+ - Deciding whether compacting or summarizing context/docs is appropriate.
24
+ - Summarizing `docs/STATUS.md`, `docs/TASKS.md`, `docs/WORKLOG.md`, or memory snippets into compact handoff notes.
25
+ - Deciding fast vs slow lane, safe vs risky lane, whether a skill should be activated, and whether the current prompt has enough steps planned.
26
+ - Choosing compact-now vs compact-later and summarize-vs-keep-detail for UKit context hygiene.
27
+ - Keeping agent context compact without removing existing lanes: Claude PreCompact/reinject stays active, OpenCode native auto/prune compaction stays active, and Codex Desktop soft handoffs use `compact.codexContext.compactTarget` (default 150 lines; preferred 120-150; hard max 170) while preserving critical state.
28
+ - Small, reversible UKit runtime maintenance decisions.
29
+
30
+ ## Never Use For
31
+
32
+ - Security/auth/permission/secrets work.
33
+ - Release, tag, publish, package ownership, or irreversible registry decisions.
34
+ - Data deletion beyond clearly safe local queue/doc cleanup.
35
+ - Architecture decisions, roadmap trade-offs, or deep product reasoning.
36
+ - Product code changes or risky/shared implementation edits.
37
+ - Any action where the consequence is not easy to inspect and undo.
38
+
39
+ ## Workflow
40
+
41
+ 1. Read the smallest relevant local state file first.
42
+ 2. Identify whether the requested action is safe and reversible.
43
+ 3. If risky, hand back to the main model with a concise reason; do not ask the end user and do not block the main AI flow.
44
+ 4. If the main task can continue without this sidecar result, return a compact recommendation and let the main task proceed immediately.
45
+ 5. If safe, make the smallest cleanup/summary/classification/routing decision.
46
+ 6. Follow the CoDev priority order: output quality first, then safety, then speed, then token discipline. Spend tokens/steps when they protect correctness.
47
+ 7. Use small step budgets: trivial = 1 step, simple = up to 2 steps, non-trivial = up to 4 planned steps before the main model reassesses.
48
+ 8. For long sessions, preserve existing Claude/OpenCode auto-compact behavior; for Codex Desktop, target a compact handoff of about 120-150 lines, never above 170 lines for this lane, not a full transcript replay.
49
+ 9. Preserve user-owned content; do not delete unclear tasks.
50
+ 10. Report exactly what changed or what decision was made.
51
+
52
+ ## Output Format
53
+
54
+ ```
55
+ STATUS: DONE | SKIPPED | HAND_BACK
56
+ MODEL_HINT: subagents.smallTaskModel=<value-or-default>
57
+ SUMMARY: [one sentence]
58
+ CHANGES:
59
+ - [file/area]: [what changed]
60
+ HAND_BACK_REASON: [only if HAND_BACK]
61
+ NEXT: [small follow-up or "none"]
62
+ ```
63
+
64
+ ## Guardrails
65
+
66
+ - Keep UKit invisible: never ask end users to remember this agent, model name, or config path.
67
+ - Never block AI decision-making; escalate to the main model instead of waiting for user input when risk exceeds this lane.
68
+ - Never block, replace, or slow the user's main task; this lane is a sidecar hint/maintenance worker only.
69
+ - Prefer no-op over destructive cleanup when uncertain.
70
+ - Do not invent product requirements.
71
+ - Never choose cheap/fast if it risks wrong context, weak verification, over-compaction, or lower answer quality.
72
+ - Do not widen reads beyond local UKit state unless the main model explicitly scoped the handoff.
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: ukit-vision-analyst
3
+ description: "The only lane permitted to interpret images in this repo. Use whenever a prompt references, attaches, or points at an image (screenshot, mockup, diagram, photo of an error) and the caller needs to know what is actually in it. unic-code/unic-smart cannot read images on this gateway and must never guess at their contents — route image analysis here instead. Reports findings only; never writes product code."
4
+ model: "@vision" # real gateway model name, NOT an alias. unic-code/unic-smart cannot read images on this gateway — do NOT "fix" this to sonnet/opus.
5
+ tools: ["read","glob","bash"]
6
+ ---
7
+
8
+ You are UKit's vision analyst. Your only job is to look at real image files and report what is
9
+ actually in them. You never write, edit, or refactor product code — you analyse and report.
10
+
11
+ ## 1. Role
12
+
13
+ - Analyse images (screenshots, mockups, diagrams, photos of errors) and report their content
14
+ faithfully.
15
+ - Never write product code. `Edit`/`Write` are deliberately absent from your `tools`; you report
16
+ via `Bash` (to write your receipt), you never implement.
17
+ - Stay end-user-invisible: this lane is internal UKit orchestration, not something end users invoke
18
+ by name.
19
+
20
+ ## 2. Model self-check — first action, before touching any image
21
+
22
+ Before reading any image, determine the model you are actually running on right now.
23
+
24
+ - Vision-capable means: `unic-vision`, or — when `unicMode` is off — whatever
25
+ `modelTiers.vision.fallbackModel` resolves to per
26
+ `node .claude/ukit/index/unic-gateway.mjs --json`.
27
+ - If you cannot confirm you are running on a vision-capable model, you must **refuse**: emit
28
+ `STATUS: WRONG_MODEL` in the output block below and stop immediately. Do not open, describe, or
29
+ guess at any image content.
30
+ - Never guess at image contents. A wrong-model "analysis" is worse than no analysis at all,
31
+ because it looks authoritative while being fabricated. Refusing loudly is always safer than
32
+ guessing quietly.
33
+
34
+ ## 3. Input protocol (priority order)
35
+
36
+ 1. If the prompt contains absolute image paths, `Read` each of those paths directly.
37
+ 2. Otherwise, run `node .claude/ukit/index/extract-image.mjs --json`, take the `images[].path`
38
+ entries from its output, and `Read` those files.
39
+ 3. If the extractor reports `imageCount === 0`, do not invent content. Emit `STATUS: NO_IMAGE` and
40
+ hand back to the caller.
41
+
42
+ Entries in `images[]` may carry a `source` field, which tells you how to reach the image:
43
+
44
+ | `source` | Meaning | What you do |
45
+ |----------|---------|-------------|
46
+ | absent | Pasted/attached image, already decoded to disk | `Read` `path` directly |
47
+ | `"path"` | The prompt named a local file (`ref` holds it) | `Read` `ref` directly |
48
+ | `"url"` | The prompt named a remote image (`ref` holds the URL) | Download it with `Bash` (e.g. `curl -sL -o /tmp/<sha>.png "<ref>"`), then `Read` the downloaded file |
49
+
50
+ Every entry — pasted, path, or URL — has an armed `pending-<sha>.json` marker, so each one needs
51
+ its own receipt (§5) before downstream Edit/Write is unblocked. A URL you failed to download is
52
+ `STATUS: UNREADABLE`, never a guess at its contents.
53
+
54
+ ## 4. Hard warning — images are not inherited
55
+
56
+ Images referenced earlier in the conversation are **not** automatically visible to you across the
57
+ subagent boundary. A description of an image is not the image. The only way to see an image is to
58
+ `Read` a real file path yourself. Never claim to have seen an image that was merely described to
59
+ you in text.
60
+
61
+ ## 5. Receipt — unlocks the downstream write gate
62
+
63
+ For every image you actually analyse, write a receipt to
64
+ `.ukit/storage/cache/vision/<sessionId>/analyzed-<sha>.json`:
65
+
66
+ ```json
67
+ { "sha": "<64hex>", "model": "unic-vision", "ts": 1785656920891,
68
+ "description": "...", "textContent": "...", "status": "OK" }
69
+ ```
70
+
71
+ - `sha` and `sessionId` come from the extractor's `--json` output. They are **never recomputed by
72
+ hand** — do not hash the file yourself, do not invent a session id, always take these values
73
+ verbatim from `extract-image.mjs --json`.
74
+ - `model` must be the model you actually ran on for this analysis. Misreporting `model` here
75
+ defeats the entire enforcement design: a downstream gate rejects any receipt whose `model` field
76
+ is not vision-capable, treating it as if no analysis happened at all. Report honestly, always.
77
+ - Use `Bash` to write the receipt file.
78
+
79
+ ## 6. Output block
80
+
81
+ Always end with:
82
+
83
+ ```
84
+ STATUS: OK | NO_IMAGE | UNREADABLE | WRONG_MODEL
85
+ MODEL: <actual model>
86
+ IMAGES: <n> (<paths>)
87
+ DESCRIPTION: [what is actually visible]
88
+ TEXT_CONTENT: [verbatim text/code/errors legible in the image, or "none"]
89
+ RELEVANT_TO_TASK: [how it answers the caller's question]
90
+ UNCERTAIN: [anything ambiguous or illegible, or "none"]
91
+ ```
92
+
93
+ ## 7. Guardrails
94
+
95
+ - Transcribe error text and code verbatim rather than paraphrasing it.
96
+ - State uncertainty explicitly instead of guessing — use `UNCERTAIN:` for anything ambiguous or
97
+ illegible.
98
+ - Do not read unrelated repo files; stay scoped to the image(s) and the immediate task question.
99
+ - Stay end-user-invisible: end users should never need to know this agent's name or invoke it
100
+ directly.
@@ -0,0 +1,90 @@
1
+ # UNIC gateway model names are LITERAL model names, not aliases, and they ship as the
2
+ # installed default. Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
3
+ #
4
+ # Non-UNIC omp provider? A maintainer edits the three cost tiers to the values in
5
+ # orchestration.modelTiers[*].claudeModel (.ukit/storage/config.json) — currently
6
+ # lite: claude-haiku-4-5, code: claude-sonnet-5, smart: claude-opus-5.
7
+ # `vision` STAYS unic-vision: its claudeModel is already unic-vision, because
8
+ # unic-code/unic-smart cannot read images on this gateway. claude-sonnet-5 is only
9
+ # modelTiers.vision.fallbackModel, used when unicMode is off — never a drop-in here.
10
+ modelRoles:
11
+ lite: unic-lite
12
+ code: unic-code
13
+ smart: unic-smart
14
+ vision: unic-vision
15
+
16
+ tools:
17
+ approvalMode: write
18
+ approval:
19
+ bash: allow
20
+ eval: prompt
21
+
22
+ bash:
23
+ # Translated from templates/.claude/hooks/block-dangerous.sh's DANGEROUS_PATTERNS array.
24
+ # `deny`/`prompt` fire on the WHOLE command or on ANY compound segment (e.g. `foo && rm -rf /`
25
+ # still hits the `rm -rf /*` entry below) — so a `deny` entry below the trailing allow still
26
+ # catches it. `allow` only ever matches an ENTIRE, non-compound command, so the trailing "*"
27
+ # allow is NOT a universal escape hatch: any compound command (`&&`, `;`, `|`) not itself caught
28
+ # by a `deny`/`prompt` entry falls through to `tools.approvalMode` instead of being auto-allowed.
29
+ # `bash.patterns` does not cover the `eval` tool at all — that is why `tools.approval.eval` above
30
+ # is `prompt`, and why TASK-004's bridge separately maps `eval` -> `Bash`.
31
+ patterns:
32
+ - match: "rm -rf /*"
33
+ approval: deny
34
+ - match: "rm -rf ~*"
35
+ approval: deny
36
+ - match: "rm -rf ."
37
+ approval: deny
38
+ - match: "rm -rf .."
39
+ approval: deny
40
+ - match: "git push --force*"
41
+ approval: deny
42
+ - match: "git push -f *"
43
+ approval: deny
44
+ - match: "git reset --hard*"
45
+ approval: deny
46
+ - match: "git clean -fd*"
47
+ approval: deny
48
+ - match: "git checkout .*"
49
+ approval: deny
50
+ - match: "git restore .*"
51
+ approval: deny
52
+ - match: "*> /dev/sda*"
53
+ approval: deny
54
+ - match: "mkfs.*"
55
+ approval: deny
56
+ - match: ":(){ :|:& };:*"
57
+ approval: deny
58
+ - match: "dd if=/dev/*"
59
+ approval: deny
60
+ # block-dangerous.sh also carries a safe-cleanup allowlist for recursive force-deletes
61
+ # (dist/build/coverage/.next/.nuxt/.turbo/tmp/temp/.cache/node_modules) and blocks every other
62
+ # `rm -rf`. bash.patterns has no conditional/allowlist syntax to express that distinction, so
63
+ # this layer is deliberately coarser: it denies ALL remaining `rm -rf` rather than approximating
64
+ # the allowlist. The real script still runs via TASK-004's bridge and is the authority here —
65
+ # bash.patterns is defence in depth, not the only line.
66
+ - match: "rm -rf *"
67
+ approval: deny
68
+ - match: "*"
69
+ approval: allow
70
+
71
+ # UKit owns memory; omp's memory subsystem stays off. See PLAN.md §3 D12 —
72
+ # enabling it would inject a second, independent memory stream into the same
73
+ # context window that auto-compact exists to protect. Do not "fix" this to local.
74
+ # UKit's own memory already lives at .ukit/storage/memory/ and is reinjected by
75
+ # reinject-context.sh; there is no shared eviction policy between the two systems.
76
+ memory:
77
+ backend: off
78
+
79
+ # Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
80
+ # calls (compact.hardCapTokens, default 220000). Mirrors the Claude Code value
81
+ # env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = 180000 in templates/.claude/settings.json.
82
+ #
83
+ # Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
84
+ # fixed token limit for context maintenance and overrides the percentage threshold
85
+ # when set; omp's own default is -1 ("use compaction.thresholdPercent"). The key this
86
+ # file shipped with in 2.2.1 — `compact.autoCompactWindow` — was a plan-time guess and
87
+ # omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
88
+ # setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
89
+ compaction:
90
+ thresholdTokens: 180000
@@ -0,0 +1,368 @@
1
+ // ukit-bridge.js — omp hook bridge for UKit.
2
+ //
3
+ // Bridge, don't fork (PLAN.md D1): the 18 `.sh` scripts under
4
+ // `.claude/hooks/` are the single source of truth for hook behaviour across
5
+ // both Claude Code and omp. This module never re-implements their logic in
6
+ // JS. It only:
7
+ // 1. Maps an omp event + tool name onto the ordered list of scripts that
8
+ // `.claude/settings.json` would have run for the equivalent Claude Code
9
+ // event (see HOOK_EVENT_MAP, generated by hand from
10
+ // `templates/.claude/settings.json` — keep them in sync).
11
+ // 2. Builds the same stdin JSON payload the scripts already expect
12
+ // (hook_event_name, tool_name, tool_input, session_id, cwd, ...).
13
+ // 3. Executes each script via `pi.exec()` (never spawns/copies the script
14
+ // body) and translates the exit code back into an omp-shaped result.
15
+ //
16
+ // Event NAMES are verified against omp v17.4.2 (2026-08-22): its extension API
17
+ // registers `tool_call`, `tool_result`, `turn_start`, `session_start`,
18
+ // `session.compacting` and `session_compact` exactly as spelled below.
19
+ //
20
+ // `pi.exec`'s argument shape is still unverified — the contract chosen below (see
21
+ // the doc comments on each exported function) is internally consistent and is
22
+ // exercised end-to-end against a fake `pi` in tests/hooks/ompHookBridge.test.js,
23
+ // but nothing here has observed omp actually invoking it.
24
+
25
+ import path from 'node:path';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Event -> script mapping (hand-derived from templates/.claude/settings.json;
29
+ // keep this literally in sync with that file — case 1 in
30
+ // tests/hooks/ompHookBridge.test.js re-parses settings.json and asserts
31
+ // exact equality against this table).
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export const HOOK_EVENT_MAP = {
35
+ tool_call: {
36
+ 'Read|Grep|Glob': ['skill-router.sh'],
37
+ 'Edit|Write': [
38
+ 'protect-files.sh',
39
+ 'stale-spec-guard.sh',
40
+ 'pre-edit-backup.sh',
41
+ 'skill-router.sh',
42
+ 'handoff-model-guard.sh',
43
+ 'vision-gate.sh',
44
+ 'context-hardcap-gate.sh',
45
+ ],
46
+ Bash: [
47
+ 'verification-guard.sh',
48
+ 'auto-allow-bash.sh',
49
+ 'skill-router.sh',
50
+ 'block-dangerous.sh',
51
+ 'handoff-model-guard.sh',
52
+ 'context-hardcap-gate.sh',
53
+ ],
54
+ },
55
+ tool_result: {
56
+ 'Edit|Write': ['post-edit-verify.sh'],
57
+ Bash: ['compress-output.sh'],
58
+ },
59
+ turn_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
60
+ 'session.compacting': ['reinject-context.sh'],
61
+ session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Tool-name mapping (PLAN.md D2).
66
+ //
67
+ // omp's write surface is `edit`, `write`, AND `ast_edit` — `ast_edit` must
68
+ // map to the same `Edit` matcher group as `edit`/`write`, or it silently
69
+ // bypasses protect-files.sh / vision-gate.sh. `eval` is omp's shell-capable
70
+ // tool and must map to `Bash` to hit block-dangerous.sh / verification-guard.sh.
71
+ // Any tool name not in this table maps to `null`, which runs ZERO scripts —
72
+ // it must never silently fall back to Bash or Edit.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const TOOL_NAME_MAP = {
76
+ read: 'Read',
77
+ grep: 'Grep',
78
+ glob: 'Glob',
79
+ edit: 'Edit',
80
+ write: 'Write',
81
+ ast_edit: 'Edit',
82
+ eval: 'Bash',
83
+ bash: 'Bash',
84
+ };
85
+
86
+ export function mapToolName(ompToolName) {
87
+ return TOOL_NAME_MAP[ompToolName] ?? null;
88
+ }
89
+
90
+ function matcherGroupFor(claudeToolName) {
91
+ if (claudeToolName === 'Read' || claudeToolName === 'Grep' || claudeToolName === 'Glob') {
92
+ return 'Read|Grep|Glob';
93
+ }
94
+ if (claudeToolName === 'Edit' || claudeToolName === 'Write') {
95
+ return 'Edit|Write';
96
+ }
97
+ if (claudeToolName === 'Bash') {
98
+ return 'Bash';
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Fail-direction classification (PLAN.md D3). Not a blanket rule -- each
105
+ // script's own header documents its own fail direction; this table is a
106
+ // transcription of those 18 headers, not an invented policy. Gate scripts
107
+ // fail CLOSED (non-zero/throw => block). Advisory scripts fail OPEN
108
+ // (non-zero/throw => log a warning, never block).
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export const FAIL_CLOSED_SCRIPTS = new Set([
112
+ 'protect-files.sh',
113
+ 'stale-spec-guard.sh',
114
+ 'handoff-model-guard.sh',
115
+ 'vision-gate.sh',
116
+ 'context-hardcap-gate.sh',
117
+ 'block-dangerous.sh',
118
+ 'verification-guard.sh',
119
+ ]);
120
+
121
+ export const ADVISORY_SCRIPTS = new Set([
122
+ 'skill-router.sh',
123
+ 'auto-allow-bash.sh',
124
+ 'pre-edit-backup.sh',
125
+ 'vision-router.sh',
126
+ 'context-window-guard.sh',
127
+ 'post-edit-verify.sh',
128
+ 'compress-output.sh',
129
+ 'reinject-context.sh',
130
+ 'auto-prune-bash.sh',
131
+ 'reset-compact-pressure.sh',
132
+ 'handoff-resume.sh',
133
+ ]);
134
+
135
+ function classifyFailure(scriptName) {
136
+ if (FAIL_CLOSED_SCRIPTS.has(scriptName)) return 'closed';
137
+ if (ADVISORY_SCRIPTS.has(scriptName)) return 'open';
138
+ // Unclassified script (should not happen for the 18 known scripts): fail
139
+ // open by default rather than blocking on an unknown quantity.
140
+ return 'open';
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Payload + result translation.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Builds the same stdin JSON shape the `.sh` scripts already read via
149
+ * `INPUT=$(cat)`. Fields the caller does not supply are simply omitted --
150
+ * the scripts already degrade gracefully when optional fields (e.g.
151
+ * transcript_path, prompt) are absent (see context-window-guard.sh, which
152
+ * exits 0 immediately when transcript_path is missing).
153
+ */
154
+ function buildHookPayload(hookEventName, { toolName, toolInput, sessionId, cwd, prompt, transcriptPath } = {}) {
155
+ const payload = { hook_event_name: hookEventName };
156
+ if (toolName !== undefined) payload.tool_name = toolName;
157
+ if (toolInput !== undefined) payload.tool_input = toolInput;
158
+ if (sessionId !== undefined) payload.session_id = sessionId;
159
+ if (cwd !== undefined) payload.cwd = cwd;
160
+ if (prompt !== undefined) payload.prompt = prompt;
161
+ if (transcriptPath !== undefined) payload.transcript_path = transcriptPath;
162
+ return payload;
163
+ }
164
+
165
+ /**
166
+ * Translates a single script's `pi.exec` outcome into a bridge-internal
167
+ * verdict. Exit 0 => pass. Exit 2 => this script's own explicit "block"
168
+ * signal, regardless of gate/advisory class (mirrors Claude Code's own
169
+ * "exit 2 = block, stderr = reason" contract). Any other non-zero exit, or
170
+ * a thrown error, is resolved via the script's fail-direction classification.
171
+ */
172
+ function translateExecResult(scriptName, execResult) {
173
+ const code = execResult?.code ?? 0;
174
+ const stdout = execResult?.stdout ?? '';
175
+ const stderr = execResult?.stderr ?? '';
176
+
177
+ if (code === 0) {
178
+ return { block: false, stdout, stderr };
179
+ }
180
+ if (code === 2) {
181
+ return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
182
+ }
183
+
184
+ const direction = classifyFailure(scriptName);
185
+ if (direction === 'closed') {
186
+ return {
187
+ block: true,
188
+ reason: stderr || `${scriptName} exited ${code} (failing closed)`,
189
+ stdout,
190
+ stderr,
191
+ };
192
+ }
193
+ return { block: false, warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`, stdout, stderr };
194
+ }
195
+
196
+ export { translateExecResult };
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Script chain runner -- shared by every event handler below. Keeps the
200
+ // fail-direction / short-circuit / context-accumulation logic in exactly
201
+ // one place.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Runs `scripts` (basenames under `.claude/hooks/`) in order via
206
+ * `pi.exec(absoluteScriptPath, { input: JSON.stringify(payload) })`,
207
+ * short-circuiting on the first block. Returns
208
+ * { block, reason?, context, invoked }
209
+ * where `context` is the concatenation of each script's trimmed, non-empty
210
+ * stdout (used by session.compacting / session_start to surface
211
+ * reinject-context.sh / handoff-resume.sh output back to omp).
212
+ */
213
+ export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
214
+ const invoked = [];
215
+ const contextParts = [];
216
+
217
+ for (const scriptName of scripts) {
218
+ const scriptPath = path.join(projectRoot, '.claude', 'hooks', scriptName);
219
+ invoked.push(scriptName);
220
+
221
+ let execResult;
222
+ try {
223
+ execResult = await pi.exec(scriptPath, { input: JSON.stringify(payload) });
224
+ } catch (err) {
225
+ execResult = { code: 1, stdout: '', stderr: err?.message ?? String(err) };
226
+ }
227
+
228
+ const verdict = translateExecResult(scriptName, execResult);
229
+
230
+ if (verdict.stdout && verdict.stdout.trim()) {
231
+ contextParts.push(verdict.stdout.trim());
232
+ }
233
+
234
+ if (verdict.warning) {
235
+ pi.logger?.warn?.(verdict.warning);
236
+ }
237
+
238
+ if (verdict.block) {
239
+ return { block: true, reason: verdict.reason, context: contextParts.join('\n'), invoked };
240
+ }
241
+ }
242
+
243
+ return { block: false, context: contextParts.join('\n'), invoked };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Event handlers (all exported directly for test import; wired onto `pi.on`
248
+ // by the default export below).
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function scriptsForToolCall(claudeToolName) {
252
+ const matcherGroup = matcherGroupFor(claudeToolName);
253
+ if (!matcherGroup) return [];
254
+ return HOOK_EVENT_MAP.tool_call[matcherGroup] ?? [];
255
+ }
256
+
257
+ function scriptsForToolResult(claudeToolName) {
258
+ const matcherGroup = matcherGroupFor(claudeToolName);
259
+ // tool_result only has script chains for Edit|Write and Bash.
260
+ if (matcherGroup !== 'Edit|Write' && matcherGroup !== 'Bash') return [];
261
+ return HOOK_EVENT_MAP.tool_result[matcherGroup] ?? [];
262
+ }
263
+
264
+ /**
265
+ * @param {object} event - { tool, input, sessionId, cwd }
266
+ */
267
+ export async function runToolCall(pi, event, { projectRoot }) {
268
+ const toolName = mapToolName(event.tool);
269
+ const scripts = scriptsForToolCall(toolName);
270
+ const payload = buildHookPayload('PreToolUse', {
271
+ toolName,
272
+ toolInput: event.input,
273
+ sessionId: event.sessionId,
274
+ cwd: event.cwd,
275
+ });
276
+ const result = await runScriptChain(pi, scripts, payload, { projectRoot });
277
+ return { ...result, toolName };
278
+ }
279
+
280
+ export async function runToolResult(pi, event, { projectRoot }) {
281
+ const toolName = mapToolName(event.tool);
282
+ const scripts = scriptsForToolResult(toolName);
283
+ const payload = buildHookPayload('PostToolUse', {
284
+ toolName,
285
+ toolInput: event.input,
286
+ sessionId: event.sessionId,
287
+ cwd: event.cwd,
288
+ });
289
+ const result = await runScriptChain(pi, scripts, payload, { projectRoot });
290
+ return { ...result, toolName };
291
+ }
292
+
293
+ export async function runTurnStart(pi, event, { projectRoot }) {
294
+ const payload = buildHookPayload('UserPromptSubmit', {
295
+ sessionId: event.sessionId,
296
+ cwd: event.cwd,
297
+ prompt: event.prompt,
298
+ });
299
+ return runScriptChain(pi, HOOK_EVENT_MAP.turn_start, payload, { projectRoot });
300
+ }
301
+
302
+ // `session_before_compact`'s CANCEL capability is explicitly out of scope
303
+ // this cycle (PLAN.md Known gaps) -- this bridge does not wire that event.
304
+ export async function runSessionCompacting(pi, event, { projectRoot }) {
305
+ const payload = buildHookPayload('PreCompact', {
306
+ sessionId: event.sessionId,
307
+ cwd: event.cwd,
308
+ transcriptPath: event.transcriptPath,
309
+ });
310
+ return runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
311
+ }
312
+
313
+ // Claude Code's `SessionStart` entry in settings.json carries NO matcher, so it
314
+ // fires on every source including `compact`. Reaching that same behaviour on omp
315
+ // takes two events, not one -- see runSessionCompact below.
316
+ //
317
+ // handoff-resume.sh is idempotent by design (reads + prints the
318
+ // docs/AI_HANDOFF/RUN.md cursor, never advances state), so running this chain
319
+ // more than once in a session is safe (PLAN.md D11).
320
+ export async function runSessionStart(pi, event, { projectRoot }) {
321
+ const payload = buildHookPayload('SessionStart', {
322
+ sessionId: event.sessionId,
323
+ cwd: event.cwd,
324
+ });
325
+ return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
326
+ }
327
+
328
+ // RESOLVED 2026-08-22 against a real omp v17.4.2 install. PLAN.md D11 carried this
329
+ // as a `TODO(verify)`: "does omp emit `session_start` on a post-compact
330
+ // continuation?" It does not -- and it does not need to, because omp exposes a
331
+ // dedicated post-compaction event instead. Its extension API registers
332
+ // `session_before_compact` -> `session.compacting` -> `session_compact`, the last
333
+ // firing after compaction settles and carrying the resulting `compactionEntry`.
334
+ //
335
+ // Without this handler the omp side lost the whole post-compact chain: the run
336
+ // cursor stayed un-surfaced, compact pressure was never reset, and a mid-cycle
337
+ // handoff silently failed to resume after a compaction -- the exact failure the
338
+ // plan named. Mapping `session_compact` onto the SAME script chain restores
339
+ // parity with Claude Code's matcher-less SessionStart.
340
+ //
341
+ // The payload keeps `hook_event_name: 'SessionStart'` deliberately: the scripts
342
+ // are shared with Claude Code and know that name, and Claude Code reaches them by
343
+ // the same event with `source: "compact"`. Inventing an omp-only event name here
344
+ // would mean forking the scripts, which is what this bridge exists to avoid.
345
+ export async function runSessionCompact(pi, event, { projectRoot }) {
346
+ const payload = buildHookPayload('SessionStart', {
347
+ sessionId: event.sessionId,
348
+ cwd: event.cwd,
349
+ source: 'compact',
350
+ });
351
+ return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Default export -- omp hook factory contract: `export default function
356
+ // hook(pi) { pi.on(event, handler) }`.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export default function hook(pi) {
360
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
361
+
362
+ pi.on('tool_call', (event) => runToolCall(pi, event, { projectRoot }));
363
+ pi.on('tool_result', (event) => runToolResult(pi, event, { projectRoot }));
364
+ pi.on('turn_start', (event) => runTurnStart(pi, event, { projectRoot }));
365
+ pi.on('session.compacting', (event) => runSessionCompacting(pi, event, { projectRoot }));
366
+ pi.on('session_start', (event) => runSessionStart(pi, event, { projectRoot }));
367
+ pi.on('session_compact', (event) => runSessionCompact(pi, event, { projectRoot }));
368
+ }