@@ -31,7 +31,17 @@ import { VENDORS } from "./constants.ts";
3131import { resolveGitRoot } from "./fs-utils.ts" ;
3232import { clearGrokContext } from "./grok-context.ts" ;
3333import { makePromptOutput } from "./hook-output.ts" ;
34- import type { ModeState , Vendor } from "./types.ts" ;
34+ // triggers.json is imported statically: the bundler inlines it into the oma
35+ // binary (bundled `oma hook` path needs no file on disk), while a standalone
36+ // bun run resolves the sibling file next to this module (pi / direct run).
37+ import embeddedTriggers from "./triggers.json" with { type : "json" } ;
38+ import type {
39+ HandlerCtx ,
40+ HandlerResult ,
41+ HookInput ,
42+ ModeState ,
43+ Vendor ,
44+ } from "./types.ts" ;
3545
3646// ── Unicode normalization ─────────────────────────────────────
3747
@@ -377,9 +387,9 @@ interface TriggerConfig {
377387 extensionRouting ?: Record < string , string [ ] > ;
378388}
379389
390+ /** Load the triggers config from the embedded (bundler-inlined / sibling-resolved) JSON. */
380391function loadConfig ( ) : TriggerConfig {
381- const configPath = join ( import . meta. dirname , "triggers.json" ) ;
382- return JSON . parse ( readFileSync ( configPath , "utf-8" ) ) ;
392+ return structuredClone ( embeddedTriggers ) as TriggerConfig ;
383393}
384394
385395function detectLanguage ( projectDir : string ) : string {
@@ -742,37 +752,32 @@ export function deactivateAllPersistentModes(
742752 }
743753}
744754
745- // ── Main ───────────────────────── ─────────────────────────────
755+ // ── Pure handler (canonical ABI) ─────────────────────────────
746756
747- async function main ( ) {
748- const raw = readFileSync ( 0 , "utf-8" ) ;
749- let input : Record < string , unknown > ;
750- try {
751- input = JSON . parse ( raw ) ;
752- } catch {
753- process . exit ( 0 ) ;
754- }
755-
756- // Guard 1: Only process genuine user prompts — skip agent-generated content
757- if ( ! isGenuineUserPrompt ( input ) ) process . exit ( 0 ) ;
758-
759- const vendor = detectVendor ( input ) ;
760- const projectDir = getProjectDir ( vendor , input ) ;
761- const sessionId = getSessionId ( input ) ;
762- let prompt = ( input . prompt as string ) ?? "" ;
757+ /**
758+ * Pure decision function — the single logic source for keyword detection.
759+ *
760+ * Called in-process by `oma hook` dispatch (Task 3+) and by the standalone
761+ * `main()` entry below (pi subprocess path). Both paths share exactly this
762+ * code; no business logic is duplicated.
763+ *
764+ * Returns a `context` HandlerResult when a workflow keyword matches, or
765+ * `null` when no match / early-exit condition (no stdout side-effect here).
766+ *
767+ * NOTE: `ctx.cwd` is expected to be the resolved git-root project directory,
768+ * as computed by `getProjectDir()` in the standalone path.
769+ */
770+ export async function run (
771+ input : HookInput ,
772+ ctx : HandlerCtx ,
773+ ) : Promise < HandlerResult | null > {
774+ if ( input . kind !== "prompt" ) return null ;
763775
764- // agy's PreInvocation stdin carries no `prompt` — recover the user request
765- // from the transcript. PreInvocation fires before every model call, so only
766- // act on the first invocation of a turn (invocationNum) to avoid re-running
767- // keyword detection mid-turn.
768- if ( vendor === "antigravity" && ! prompt ) {
769- const invocationNum = input . invocationNum ;
770- if ( typeof invocationNum === "number" && invocationNum > 1 ) process . exit ( 0 ) ;
771- prompt = readAgyPrompt ( input . transcriptPath ) ;
772- }
776+ const { prompt } = input ;
777+ const { vendor, cwd : projectDir , sid : sessionId = "unknown" } = ctx ;
773778
774- if ( ! prompt . trim ( ) ) process . exit ( 0 ) ;
775- if ( startsWithSlashCommand ( prompt ) ) process . exit ( 0 ) ;
779+ if ( ! prompt . trim ( ) ) return null ;
780+ if ( startsWithSlashCommand ( prompt ) ) return null ;
776781
777782 const config = loadConfig ( ) ;
778783 const lang = detectLanguage ( projectDir ) ;
@@ -782,14 +787,12 @@ async function main() {
782787 deactivateAllPersistentModes ( projectDir , sessionId ) ;
783788 // Grok's resume context lives in a session-start file, not L1 stdout — clear it.
784789 if ( vendor === "grok" ) clearGrokContext ( projectDir ) ;
785- process . exit ( 0 ) ;
790+ return null ;
786791 }
792+
787793 const infoPatterns = buildInformationalPatterns ( config , lang ) ;
788794 // Guard 2: Strip code blocks, inline code, and pasted system-echo blocks
789- // before scanning for keywords. System echo stripping prevents oma's own
790- // hook outputs (when pasted back into the prompt) from re-triggering.
791- // NFKC normalization collapses fullwidth Latin from CJK IMEs onto ASCII
792- // so keyword regexes cannot be silently bypassed by parallel-style input.
795+ // before scanning for keywords. NFKC normalization collapses fullwidth Latin.
793796 const cleaned = normalizeForMatching (
794797 stripSystemEchoes ( stripCodeBlocks ( prompt ) ) ,
795798 ) ;
@@ -803,18 +806,11 @@ async function main() {
803806
804807 for ( const [ workflow , def ] of Object . entries ( config . workflows ) ) {
805808 if ( excluded . has ( workflow ) ) continue ;
806-
807- // Global CLI-invocation guard: prompts that start with a CLI invocation
808- // of `oma` or a `VENDORS` entry are tool invocations, not natural-language
809- // workflow requests. Skip silently to avoid false-positive matches.
810809 if ( shouldSkipAllWorkflows ( cleaned ) ) continue ;
811810
812- // Per-workflow override: if a predicate is registered for this specific
813- // workflow, evaluate it and skip just this workflow when it returns true.
814811 const workflowPredicate = KEYWORD_SKIP_PREDICATES [ workflow ] ;
815812 if ( workflowPredicate ?.( cleaned ) ) continue ;
816813
817- // Analytical questions should never trigger persistent workflows
818814 if ( analytical && def . persistent ) continue ;
819815
820816 const patterns = [
@@ -826,18 +822,14 @@ async function main() {
826822 const match = pattern . exec ( cleaned ) ;
827823 if ( ! match ) continue ;
828824 if ( isInformationalContext ( cleaned , match . index , infoPatterns ) ) continue ;
829- // Keywords deep in long prompts are likely pasted content, not user intent
830825 if ( isPastedContent ( match . index , def . persistent , cleaned . length ) )
831826 continue ;
832-
833- // Guard 3: Suppress if same workflow triggered too many times in 60s
834827 if ( isReinforcementSuppressed ( kwState , workflow ) ) continue ;
835828
836829 if ( def . persistent ) {
837830 activateMode ( projectDir , workflow , sessionId ) ;
838831 }
839832 await activateL1WorkflowSession ( projectDir , workflow , vendor , sessionId ) ;
840- // Record this trigger for reinforcement tracking
841833 const updatedState = recordKwTrigger ( kwState , workflow ) ;
842834 saveKwState ( projectDir , updatedState ) ;
843835
@@ -860,13 +852,50 @@ async function main() {
860852 }
861853 }
862854
863- const context = contextLines . join ( "\n" ) ;
864-
865- process . stdout . write ( makePromptOutput ( vendor , context ) ) ;
866- process . exit ( 0 ) ;
855+ return { type : "context" , additionalContext : contextLines . join ( "\n" ) } ;
867856 }
868857 }
869858
859+ return null ;
860+ }
861+
862+ // ── Standalone entry (pi subprocess / direct bun invocation) ──
863+
864+ async function main ( ) {
865+ const raw = readFileSync ( 0 , "utf-8" ) ;
866+ let input : Record < string , unknown > ;
867+ try {
868+ input = JSON . parse ( raw ) ;
869+ } catch {
870+ process . exit ( 0 ) ;
871+ }
872+
873+ // Guard 1: Only process genuine user prompts — skip agent-generated content
874+ if ( ! isGenuineUserPrompt ( input ) ) process . exit ( 0 ) ;
875+
876+ const vendor = detectVendor ( input ) ;
877+ const projectDir = getProjectDir ( vendor , input ) ;
878+ const sessionId = getSessionId ( input ) ;
879+ let prompt = ( input . prompt as string ) ?? "" ;
880+
881+ // agy's PreInvocation stdin carries no `prompt` — recover the user request
882+ // from the transcript. PreInvocation fires before every model call, so only
883+ // act on the first invocation of a turn (invocationNum) to avoid re-running
884+ // keyword detection mid-turn.
885+ if ( vendor === "antigravity" && ! prompt ) {
886+ const invocationNum = input . invocationNum ;
887+ if ( typeof invocationNum === "number" && invocationNum > 1 ) process . exit ( 0 ) ;
888+ prompt = readAgyPrompt ( input . transcriptPath ) ;
889+ }
890+
891+ // Build canonical inputs and delegate to run() — single logic source.
892+ const hookInput : HookInput = { kind : "prompt" , prompt, cwd : projectDir } ;
893+ const ctx : HandlerCtx = { vendor, cwd : projectDir , sid : sessionId } ;
894+
895+ const result = await run ( hookInput , ctx ) ;
896+ if ( result && result . type === "context" ) {
897+ process . stdout . write ( makePromptOutput ( vendor , result . additionalContext ) ) ;
898+ }
870899 process . exit ( 0 ) ;
871900}
872901
0 commit comments