From ee8af44610e0f3e1d89638acaffe02e9e5593b2b Mon Sep 17 00:00:00 2001 From: gracefullight <11773683+gracefullight@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:58:05 +0000 Subject: [PATCH] chore(deps): update oh-my-agent skills --- .agents/agents/pm-planner.md | 2 +- .agents/agents/variants/claude.json | 20 +- .agents/agents/variants/commandcode.json | 20 +- .agents/agents/variants/grok.json | 20 +- .agents/agents/variants/kiro.json | 20 +- .agents/agents/variants/opencode.json | 22 + .agents/config/defaults.yaml | 21 +- .agents/hooks/core/agentmemory-client.ts | 128 +- .agents/hooks/core/constants.ts | 15 +- .agents/hooks/core/hook-output.ts | 97 +- .agents/hooks/core/hud.ts | 140 +- .agents/hooks/core/keyword-detector.ts | 430 ++++-- .agents/hooks/core/persistent-mode.ts | 61 +- .agents/hooks/core/prompt-input.ts | 45 + .agents/hooks/core/serena-primer.ts | 77 +- .agents/hooks/core/skill-injector.ts | 79 +- .agents/hooks/core/state-boundary.ts | 79 +- .agents/hooks/core/test-filter.ts | 108 +- .agents/hooks/core/triggers.json | 65 +- .agents/hooks/core/types.ts | 17 +- .agents/hooks/core/vendor-detect.ts | 112 ++ .agents/hooks/core/vendor-renderer.ts | 2 +- .agents/hooks/variants/antigravity.json | 5 - .agents/hooks/variants/claude.json | 18 +- .agents/hooks/variants/codex.json | 7 - .agents/hooks/variants/commandcode.json | 10 + .agents/hooks/variants/cursor.json | 20 + .agents/hooks/variants/grok.json | 2 +- .../hooks/variants/hook-variant.schema.json | 11 +- .agents/hooks/variants/kimi.json | 38 + .agents/hooks/variants/kiro.json | 1 + .agents/hooks/variants/opencode/oma.ts | 410 ++++++ .agents/hooks/variants/pi/README.md | 45 +- .agents/hooks/variants/pi/index.ts | 93 +- .agents/hooks/variants/qwen.json | 9 +- .agents/mcp.json | 15 +- .agents/mcp_config.json | 5 +- .agents/oma-config.yaml | 11 + .agents/rules/frontend.md | 3 +- .agents/rules/i18n-arb.md | 18 + .agents/rules/i18n-guide.md | 14 +- .agents/rules/market.md | 6 +- .../_shared/conditional/quality-score.md | 2 + .../_shared/core/api-contracts/README.md | 28 +- .agents/skills/_shared/core/context-budget.md | 2 +- .../skills/_shared/core/context-loading.md | 4 +- .../skills/_shared/core/evaluator-tuning.md | 82 -- .../skills/_shared/core/session-metrics.md | 14 +- .../skills/_shared/core/vendor-detection.md | 21 +- .agents/skills/_shared/runtime/event-spec.md | 14 +- .../execution-protocols/antigravity.md | 20 +- .../runtime/execution-protocols/claude.md | 14 +- .../runtime/execution-protocols/codex.md | 10 +- .../execution-protocols/commandcode.md | 10 +- .../runtime/execution-protocols/grok.md | 8 +- .../runtime/execution-protocols/kimi.md | 66 + .../runtime/execution-protocols/kiro.md | 20 +- .../runtime/execution-protocols/opencode.md | 80 ++ .../_shared/runtime/execution-protocols/pi.md | 15 +- .../runtime/execution-protocols/qwen.md | 13 +- .../skills/_shared/runtime/memory-protocol.md | 29 +- .agents/skills/_version.json | 4 +- .agents/skills/oma-architecture/SKILL.md | 4 + .../resources/api-evolution.md | 41 + .agents/skills/oma-backend/SKILL.md | 6 + .../resources/bug-report-template.md | 4 + .agents/skills/oma-design/SKILL.md | 24 +- .../oma-design/resources/anti-patterns.md | 43 +- .../oma-design/resources/asset-strategy.md | 63 + .../skills/oma-design/resources/checklist.md | 38 + .../resources/execution-protocol.md | 38 + .../oma-design/resources/getdesign-fetcher.md | 2 + .../oma-design/resources/redesign-protocol.md | 82 ++ .agents/skills/oma-docs/SKILL.md | 6 +- .agents/skills/oma-frontend/SKILL.md | 27 +- .../oma-frontend/resources/angular-rules.md | 110 ++ .../oma-frontend/resources/checklist.md | 2 + .../oma-frontend/resources/error-playbook.md | 2 + .../resources/execution-protocol.md | 2 +- .../oma-frontend/resources/tech-stack.md | 68 + .agents/skills/oma-market/SKILL.md | 52 +- .../oma-market/resources/error-playbook.md | 6 +- .../oma-market/resources/output-laws.md | 2 +- .agents/skills/oma-mobile/SKILL.md | 19 +- .../skills/oma-mobile/resources/checklist.md | 3 + .../oma-mobile/resources/error-playbook.md | 4 + .../skills/oma-mobile/resources/examples.md | 14 +- .../resources/execution-protocol.md | 2 + .../skills/oma-mobile/resources/tech-stack.md | 121 +- .agents/skills/oma-mobile/variants/README.md | 42 +- .../variants/flutter/api-template.dart | 428 ++++++ .../oma-mobile/variants/flutter/snippets.md | 1175 +++++++++++++++++ .../oma-mobile/variants/flutter/stack.yaml | 21 + .../oma-mobile/variants/flutter/tech-stack.md | 193 +++ .../variants/react-native/api-template.ts | 332 +++++ .../variants/react-native/snippets.md | 682 ++++++++++ .../variants/react-native/stack.yaml | 22 + .../variants/react-native/tech-stack.md | 187 +++ .../oma-mobile/variants/stack.schema.json | 28 +- .../variants/swift-ios/api-template.swift | 70 +- .../oma-mobile/variants/swift-ios/snippets.md | 309 ++++- .../oma-mobile/variants/swift-ios/stack.yaml | 1 + .../variants/swift-ios/tech-stack.md | 182 ++- .../resources/incident-forensics.md | 2 + .agents/skills/oma-orchestrator/SKILL.md | 14 +- .../oma-orchestrator/config/cli-config.yaml | 16 +- .../resources/memory-schema.md | 10 +- .agents/skills/oma-pm/SKILL.md | 2 +- .../skills/oma-pm/resources/error-playbook.md | 2 +- .agents/skills/oma-refactor/SKILL.md | 6 +- .../oma-refactor/resources/definition.md | 27 +- .../oma-refactor/resources/measurement.md | 9 +- .agents/skills/oma-scm/SKILL.md | 4 + .../oma-search/resources/trust-registry.md | 2 +- .agents/skills/oma-skill-creator/SKILL.md | 3 +- .../resources/ssl-lite-template.md | 12 + .../resources/validation-checklist.md | 19 + .agents/workflows/architecture.md | 6 +- .agents/workflows/brainstorm.md | 6 +- .agents/workflows/convert.md | 234 ++++ .agents/workflows/debug.md | 6 +- .agents/workflows/deepinit.md | 6 + .agents/workflows/deepsec.md | 4 +- .agents/workflows/design.md | 2 +- .agents/workflows/docs.md | 4 +- .agents/workflows/orchestrate.md | 10 +- .agents/workflows/plan.md | 10 +- .agents/workflows/ralph.md | 12 +- .agents/workflows/review.md | 6 +- .agents/workflows/schedule.md | 81 ++ .agents/workflows/scm.md | 4 +- .agents/workflows/stack-set.md | 121 +- .agents/workflows/ultrawork.md | 187 ++- .../resources/multi-review-protocol.md | 32 + .../ultrawork/resources/phase-gates.md | 2 + .agents/workflows/video.md | 36 +- .agents/workflows/work.md | 6 +- .claude/agents/backend-engineer.md | 2 +- .claude/agents/db-engineer.md | 2 +- .claude/agents/debug-investigator.md | 2 +- .claude/agents/frontend-engineer.md | 2 +- .claude/agents/mobile-engineer.md | 2 +- .claude/agents/pm-planner.md | 2 +- .claude/agents/refactor-engineer.md | 2 +- .claude/agents/research-explorer.md | 2 +- .claude/agents/tf-infra-engineer.md | 2 +- .claude/hooks/hud.ts | 140 +- .claude/hooks/oma-hook.sh | 4 +- .claude/rules/frontend.md | 3 +- .claude/rules/i18n-arb.md | 19 + .claude/rules/i18n-guide.md | 14 +- .claude/rules/market.md | 6 +- .claude/settings.json | 22 +- .claude/skills/convert/SKILL.md | 1 + .claude/skills/oma-commit | 1 - .claude/skills/schedule/SKILL.md | 1 + .codex/agents/pm-planner.toml | 2 +- .codex/config.toml | 5 +- .codex/hooks/oma-hook.sh | 4 +- .codex/skills/convert/SKILL.md | 1 + .codex/skills/schedule/SKILL.md | 1 + .cursor/agents/architecture-reviewer.md | 1 - .cursor/agents/backend-engineer.md | 1 - .cursor/agents/db-engineer.md | 1 - .cursor/agents/debug-investigator.md | 1 - .cursor/agents/docs-curator.md | 1 - .cursor/agents/frontend-engineer.md | 1 - .cursor/agents/mobile-engineer.md | 1 - .cursor/agents/pm-planner.md | 3 +- .cursor/agents/qa-reviewer.md | 1 - .cursor/agents/refactor-engineer.md | 1 - .cursor/agents/research-explorer.md | 1 - .cursor/agents/tf-infra-engineer.md | 1 - .cursor/hooks.json | 20 + .cursor/hooks/filter-test-output.sh | 52 + .cursor/hooks/oma-hook.sh | 4 +- .cursor/mcp.json | 3 +- .cursor/rules/frontend.mdc | 3 +- .cursor/rules/i18n-arb.mdc | 20 + .cursor/rules/i18n-guide.mdc | 14 +- .cursor/rules/market.mdc | 6 +- .cursor/skills/convert/SKILL.md | 1 + .cursor/skills/schedule/SKILL.md | 1 + .github/prompts/convert.prompt.md | 7 + .github/prompts/schedule.prompt.md | 7 + .github/skills/convert/SKILL.md | 1 + .github/skills/oma-commit | 1 - .github/skills/schedule/SKILL.md | 1 + .mcp.json | 3 +- .qwen/hooks/hud.ts | 140 +- .qwen/hooks/oma-hook.sh | 4 +- .qwen/settings.json | 10 +- .qwen/skills/convert/SKILL.md | 1 + .qwen/skills/schedule/SKILL.md | 1 + .serena/project.yml | 1 + AGENTS.md | 6 +- CLAUDE.md | 6 +- 197 files changed, 6982 insertions(+), 1724 deletions(-) create mode 100644 .agents/agents/variants/opencode.json create mode 100644 .agents/hooks/core/prompt-input.ts create mode 100644 .agents/hooks/core/vendor-detect.ts create mode 100644 .agents/hooks/variants/kimi.json create mode 100644 .agents/hooks/variants/opencode/oma.ts create mode 100644 .agents/rules/i18n-arb.md delete mode 100644 .agents/skills/_shared/core/evaluator-tuning.md create mode 100644 .agents/skills/_shared/runtime/execution-protocols/kimi.md create mode 100644 .agents/skills/_shared/runtime/execution-protocols/opencode.md create mode 100644 .agents/skills/oma-architecture/resources/api-evolution.md create mode 100644 .agents/skills/oma-design/resources/asset-strategy.md create mode 100644 .agents/skills/oma-design/resources/redesign-protocol.md create mode 100644 .agents/skills/oma-frontend/resources/angular-rules.md create mode 100644 .agents/skills/oma-mobile/variants/flutter/api-template.dart create mode 100644 .agents/skills/oma-mobile/variants/flutter/snippets.md create mode 100644 .agents/skills/oma-mobile/variants/flutter/stack.yaml create mode 100644 .agents/skills/oma-mobile/variants/flutter/tech-stack.md create mode 100644 .agents/skills/oma-mobile/variants/react-native/api-template.ts create mode 100644 .agents/skills/oma-mobile/variants/react-native/snippets.md create mode 100644 .agents/skills/oma-mobile/variants/react-native/stack.yaml create mode 100644 .agents/skills/oma-mobile/variants/react-native/tech-stack.md create mode 100644 .agents/workflows/convert.md create mode 100644 .agents/workflows/schedule.md create mode 100644 .claude/rules/i18n-arb.md create mode 120000 .claude/skills/convert/SKILL.md delete mode 120000 .claude/skills/oma-commit create mode 120000 .claude/skills/schedule/SKILL.md create mode 120000 .codex/skills/convert/SKILL.md create mode 120000 .codex/skills/schedule/SKILL.md create mode 100755 .cursor/hooks/filter-test-output.sh create mode 100644 .cursor/rules/i18n-arb.mdc create mode 120000 .cursor/skills/convert/SKILL.md create mode 120000 .cursor/skills/schedule/SKILL.md create mode 100644 .github/prompts/convert.prompt.md create mode 100644 .github/prompts/schedule.prompt.md create mode 120000 .github/skills/convert/SKILL.md delete mode 120000 .github/skills/oma-commit create mode 120000 .github/skills/schedule/SKILL.md create mode 120000 .qwen/skills/convert/SKILL.md create mode 120000 .qwen/skills/schedule/SKILL.md diff --git a/.agents/agents/pm-planner.md b/.agents/agents/pm-planner.md index e15d9d9..bc08426 100644 --- a/.agents/agents/pm-planner.md +++ b/.agents/agents/pm-planner.md @@ -37,7 +37,7 @@ CHARTER_CHECK: 1. **Gather**: Requirements (users, features, constraints, deployment target) 2. **Analyze**: Technical feasibility using codebase analysis -3. **Contracts**: Define API contracts (save to `.agents/skills/_shared/core/api-contracts/`) +3. **Contracts**: Define API contracts using template `.agents/skills/_shared/core/api-contracts/template.md`; save the generated contract to `.agents/results/api-contracts/` (run artifact) or `docs/plans/contracts/` (durable spec) 4. **Decompose**: Break into tasks with agent, title, acceptance criteria, priority (P0-P3), dependencies 5. **Output**: Save to `.agents/results/plan-{sessionId}.json` diff --git a/.agents/agents/variants/claude.json b/.agents/agents/variants/claude.json index 4988fbd..59cd109 100644 --- a/.agents/agents/variants/claude.json +++ b/.agents/agents/variants/claude.json @@ -3,17 +3,21 @@ "vendor": "claude", "destDir": ".claude/agents", "modelDefault": "sonnet", - "maxTurnsDefault": 20, + "maxTurnsDefault": 30, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/claude.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, + "backend-engineer": { + "maxTurns": 40 + }, + "frontend-engineer": { + "maxTurns": 40 + }, "db-engineer": { - "maxTurns": 15 + "maxTurns": 25 }, "debug-investigator": { - "maxTurns": 15 + "maxTurns": 25 }, "architecture-reviewer": { "tools": "Read, Write, Edit, Bash, Grep, Glob", @@ -21,9 +25,11 @@ }, "tf-infra-engineer": { "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 20 + "maxTurns": 30 + }, + "mobile-engineer": { + "maxTurns": 40 }, - "mobile-engineer": {}, "pm-planner": { "tools": "Read, Write, Grep, Glob, Bash", "maxTurns": 10 diff --git a/.agents/agents/variants/commandcode.json b/.agents/agents/variants/commandcode.json index c5290c3..3c00aca 100644 --- a/.agents/agents/variants/commandcode.json +++ b/.agents/agents/variants/commandcode.json @@ -3,17 +3,21 @@ "vendor": "commandcode", "destDir": ".commandcode/agents", "modelDefault": "inherit", - "maxTurnsDefault": 20, + "maxTurnsDefault": 30, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/commandcode.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, + "backend-engineer": { + "maxTurns": 40 + }, + "frontend-engineer": { + "maxTurns": 40 + }, "db-engineer": { - "maxTurns": 15 + "maxTurns": 25 }, "debug-investigator": { - "maxTurns": 15 + "maxTurns": 25 }, "architecture-reviewer": { "tools": "Read, Write, Edit, Bash, Grep, Glob", @@ -21,9 +25,11 @@ }, "tf-infra-engineer": { "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 20 + "maxTurns": 30 + }, + "mobile-engineer": { + "maxTurns": 40 }, - "mobile-engineer": {}, "pm-planner": { "tools": "Read, Write, Grep, Glob, Bash", "maxTurns": 10 diff --git a/.agents/agents/variants/grok.json b/.agents/agents/variants/grok.json index 65c1e12..bf67720 100644 --- a/.agents/agents/variants/grok.json +++ b/.agents/agents/variants/grok.json @@ -3,25 +3,31 @@ "vendor": "grok", "destDir": ".grok/agents", "modelDefault": "grok-build", - "maxTurnsDefault": 20, + "maxTurnsDefault": 30, "toolsDefault": "run_terminal_cmd, read_file, search_replace, list_dir, grep", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/grok.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, + "backend-engineer": { + "maxTurns": 40 + }, + "frontend-engineer": { + "maxTurns": 40 + }, "db-engineer": { - "maxTurns": 15 + "maxTurns": 25 }, "debug-investigator": { - "maxTurns": 15 + "maxTurns": 25 }, "architecture-reviewer": { "maxTurns": 15 }, "tf-infra-engineer": { - "maxTurns": 20 + "maxTurns": 30 + }, + "mobile-engineer": { + "maxTurns": 40 }, - "mobile-engineer": {}, "pm-planner": { "maxTurns": 10 }, diff --git a/.agents/agents/variants/kiro.json b/.agents/agents/variants/kiro.json index f89858d..fab204c 100644 --- a/.agents/agents/variants/kiro.json +++ b/.agents/agents/variants/kiro.json @@ -3,7 +3,7 @@ "vendor": "kiro", "destDir": ".kiro/agents", "modelDefault": "inherit", - "maxTurnsDefault": 20, + "maxTurnsDefault": 30, "toolsDefault": [ "read", "write", @@ -15,22 +15,28 @@ ], "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/kiro.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, + "backend-engineer": { + "maxTurns": 40 + }, + "frontend-engineer": { + "maxTurns": 40 + }, "db-engineer": { - "maxTurns": 15 + "maxTurns": 25 }, "debug-investigator": { - "maxTurns": 15 + "maxTurns": 25 }, "architecture-reviewer": { "tools": ["read", "grep", "glob", "code"], "maxTurns": 15 }, "tf-infra-engineer": { - "maxTurns": 20 + "maxTurns": 30 + }, + "mobile-engineer": { + "maxTurns": 40 }, - "mobile-engineer": {}, "pm-planner": { "tools": ["read", "grep", "glob", "shell"], "maxTurns": 10 diff --git a/.agents/agents/variants/opencode.json b/.agents/agents/variants/opencode.json new file mode 100644 index 0000000..cfb82b2 --- /dev/null +++ b/.agents/agents/variants/opencode.json @@ -0,0 +1,22 @@ +{ + "$schema": "./agent-variant.schema.json", + "vendor": "opencode", + "destDir": ".opencode/agents", + "modelDefault": "inherit", + "toolsDefault": [], + "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/opencode.md", + "agents": { + "backend-engineer": {}, + "frontend-engineer": {}, + "db-engineer": {}, + "debug-investigator": {}, + "architecture-reviewer": {}, + "tf-infra-engineer": {}, + "mobile-engineer": {}, + "pm-planner": {}, + "qa-reviewer": {}, + "docs-curator": {}, + "refactor-engineer": {}, + "research-explorer": {} + } +} diff --git a/.agents/config/defaults.yaml b/.agents/config/defaults.yaml index 1701aab..22f9523 100644 --- a/.agents/config/defaults.yaml +++ b/.agents/config/defaults.yaml @@ -22,7 +22,7 @@ agent_defaults: db: { model: "openai/gpt-5.5", effort: "high" } debug: { model: "openai/gpt-5.5", effort: "high" } tf-infra: { model: "openai/gpt-5.5", effort: "high" } - explore: { model: "google/gemini-3.1-flash-lite" } + explore: { model: "openai/gpt-5.4-mini", effort: "low" } runtime_profiles: claude: @@ -55,23 +55,8 @@ runtime_profiles: tf-infra: { model: "openai/gpt-5.5", effort: "high" } explore: { model: "openai/gpt-5.4-mini", effort: "low" } - gemini: - description: "Gemini — Google AI Pro" - agent_defaults: - orchestrator: { model: "google/gemini-3-flash" } - architecture: { model: "google/gemini-3.1-pro-preview", thinking: true } - qa: { model: "google/gemini-3-flash", thinking: true } - pm: { model: "google/gemini-3-flash" } - backend: { model: "google/gemini-3-flash", thinking: true } - frontend: { model: "google/gemini-3-flash", thinking: true } - mobile: { model: "google/gemini-3-flash", thinking: true } - db: { model: "google/gemini-3-flash", thinking: true } - debug: { model: "google/gemini-3-flash", thinking: true } - tf-infra: { model: "google/gemini-3-flash", thinking: true } - explore: { model: "google/gemini-3.1-flash-lite" } - mixed: - description: "Mixed — role-optimal vendors per agent (Claude for orchestration/QA/PM, Codex for impl, Gemini for explore)" + description: "Mixed — role-optimal vendors per agent (Claude for orchestration/QA/PM, Codex for impl/explore)" agent_defaults: orchestrator: { model: "anthropic/claude-sonnet-4-6" } architecture: { model: "anthropic/claude-opus-4-7" } @@ -83,7 +68,7 @@ runtime_profiles: db: { model: "openai/gpt-5.5", effort: "high" } debug: { model: "openai/gpt-5.5", effort: "high" } tf-infra: { model: "openai/gpt-5.5", effort: "high" } - explore: { model: "google/gemini-3.1-flash-lite" } + explore: { model: "openai/gpt-5.4-mini", effort: "low" } antigravity: description: "Antigravity CLI (agy) — nominal Gemini 3.1 Pro for impl/architecture, Gemini 3.5 Flash for orchestration & explore (agy 1.0 has no --model flag; model is config-driven)" diff --git a/.agents/hooks/core/agentmemory-client.ts b/.agents/hooks/core/agentmemory-client.ts index 5816663..50962bd 100644 --- a/.agents/hooks/core/agentmemory-client.ts +++ b/.agents/hooks/core/agentmemory-client.ts @@ -6,10 +6,6 @@ import https from "node:https"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -// AgentMemory's published version line moved from 0.11/0.12 (original design -// target) to 0.9.x service builds; accept 0.9.x and the 0.1x.x range. -const SUPPORTED = /^0\.(9|1\d)\./; - function endpointUrl(): string | null { if (process.env.OMA_NO_AGENTMEMORY === "1") return null; if (process.env.AGENTMEMORY_URL) return process.env.AGENTMEMORY_URL; @@ -32,6 +28,15 @@ function endpointUrl(): string | null { let reachable: boolean | null = null; +/** + * Test-only: clear the memoized reachability probe so cases that point + * `AGENTMEMORY_URL` at different endpoints don't leak a cached verdict into each + * other (the probe is intentionally memoized once per process at runtime). + */ +export function _resetReachableCache(): void { + reachable = null; +} + function requestAgentMemory( baseUrl: string, path: string, @@ -97,36 +102,12 @@ export async function isAgentMemoryReachable(): Promise { try { const response = await requestAgentMemory(url, "/agentmemory/health"); - if (response.statusCode < 200 || response.statusCode >= 300) { - reachable = false; - return reachable; - } - const headerVersion = response.headers["x-agentmemory-version"]; - const version = Array.isArray(headerVersion) - ? headerVersion[0] - : headerVersion; - // Recent AgentMemory releases expose the version only in the health body, - // not the `x-agentmemory-version` header. - let isAgentMemory = false; - let bodyVersion: string | undefined; - try { - const parsed = JSON.parse(response.body) as { - service?: unknown; - status?: unknown; - version?: unknown; - }; - isAgentMemory = - parsed.service === "agentmemory" || - parsed.status === "healthy" || - parsed.status === "ok"; - if (typeof parsed.version === "string") bodyVersion = parsed.version; - } catch { - // Non-JSON body — fall back to the header check below. - } - const resolvedVersion = version ?? bodyVersion; - reachable = - isAgentMemory || - (resolvedVersion !== undefined && SUPPORTED.test(resolvedVersion)); + // Capability-based acceptance: any 2xx health response from the + // explicitly configured endpoint counts as reachable. Version pinning + // proved brittle (the published line already jumped from 0.11/0.12 + // design targets to 0.9.x service builds), so payload shape and version + // are no longer gating. + reachable = response.statusCode >= 200 && response.statusCode < 300; return reachable; } catch { reachable = false; @@ -142,15 +123,63 @@ export interface RecalledFact { interface SearchResult { score?: number; + timestamp?: unknown; + created_at?: unknown; observation?: { narrative?: unknown; facts?: unknown; title?: unknown; type?: unknown; + timestamp?: unknown; + created_at?: unknown; }; } -function parseSearchResults(body: string, k: number): RecalledFact[] { +/** + * Recall TTL: facts older than this many days are dropped from the snapshot so + * stale, long-resolved decisions stop rehydrating every boundary. Default 30 + * days; set `OMA_RECALL_MAX_AGE_DAYS=0` (or a non-positive value) to disable. + * Returns the max age in ms, or null when disabled. + */ +function recallMaxAgeMs(): number | null { + const raw = process.env.OMA_RECALL_MAX_AGE_DAYS; + const days = raw === undefined ? 30 : Number(raw); + if (!Number.isFinite(days) || days <= 0) return null; + return days * 24 * 60 * 60 * 1000; +} + +/** + * Best-effort timestamp extraction from a search result. AgentMemory's response + * envelope is not contractually fixed across versions, so several candidate + * field names / locations are probed. Numeric epoch seconds are normalised to + * ms. Returns null when no parseable timestamp is present — callers then keep + * the fact (TTL filtering is fail-open, never dropping facts of unknown age). + */ +function extractTimestampMs(entry: SearchResult): number | null { + const obs = entry.observation ?? {}; + const candidates: unknown[] = [ + obs.timestamp, + obs.created_at, + entry.timestamp, + entry.created_at, + ]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate < 1e12 ? candidate * 1000 : candidate; + } + if (typeof candidate === "string" && candidate.trim()) { + const parsed = Date.parse(candidate); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +export function parseSearchResults( + body: string, + k: number, + nowMs: number = Date.now(), +): RecalledFact[] { let parsed: { results?: unknown }; try { parsed = JSON.parse(body) as { results?: unknown }; @@ -164,12 +193,20 @@ function parseSearchResults(body: string, k: number): RecalledFact[] { return Number.isFinite(raw) ? raw : 1; })(); + const maxAgeMs = recallMaxAgeMs(); + const cutoffMs = maxAgeMs === null ? null : nowMs - maxAgeMs; + const facts: RecalledFact[] = []; for (const entry of parsed.results as SearchResult[]) { const score = typeof entry.score === "number" ? entry.score : 0; // Raw `/observe` envelopes score near-zero (~0.006); enriched facts score // in the single digits. Drop the noise floor so the snapshot stays useful. if (score < minScore) continue; + // TTL: drop facts older than the cutoff (fail-open on unknown age). + if (cutoffMs !== null) { + const tsMs = extractTimestampMs(entry); + if (tsMs !== null && tsMs < cutoffMs) continue; + } const obs = entry.observation ?? {}; const narrative = typeof obs.narrative === "string" && obs.narrative.trim() @@ -198,11 +235,14 @@ export async function recallFacts( k = 5, ): Promise { if (!query.trim()) return []; - if (!(await isAgentMemoryReachable())) return []; - const url = endpointUrl(); - if (!url) return []; - + // The whole body is guarded so this honors its "never throws" contract: the + // reachability probe and endpoint resolution can throw under load (e.g. a + // socket error from the shared daemon), and an unguarded throw here blanks the + // boundary snapshot the hook would otherwise emit. Degrade to local-only. try { + if (!(await isAgentMemoryReachable())) return []; + const url = endpointUrl(); + if (!url) return []; const response = await requestAgentMemory(url, "/agentmemory/search", { method: "POST", headers: { "content-type": "application/json" }, @@ -222,11 +262,13 @@ export async function observeWithTimeout(payload: { source: string; projectDir?: string; }): Promise { - if (!(await isAgentMemoryReachable())) return false; - const url = endpointUrl(); - if (!url) return false; - + // Fully guarded (best-effort, never throws): the reachability probe and + // endpoint resolution can throw under load, and a throw here must not abort + // the hook that fired the observe. try { + if (!(await isAgentMemoryReachable())) return false; + const url = endpointUrl(); + if (!url) return false; // AgentMemory's /observe expects a hook-event envelope // (hookType, sessionId, project, cwd, timestamp) carrying the content. const cwd = payload.projectDir ?? process.cwd(); diff --git a/.agents/hooks/core/constants.ts b/.agents/hooks/core/constants.ts index 0b5e06d..a869789 100644 --- a/.agents/hooks/core/constants.ts +++ b/.agents/hooks/core/constants.ts @@ -35,9 +35,22 @@ export const VENDORS = [ "codex", "commandcode", "cursor", - "gemini", "grok", + "kimi", "kiro", "pi", "qwen", ] as const; + +/** + * Fallback session id used when no vendor session id can be resolved from the + * hook stdin (`getSessionId`). It is shared across BOTH the keyword-detector and + * persistent-mode hooks so the two stay in sync. + * + * A persistent-mode state file keyed to this value cannot be isolated per + * session: any later session whose id ALSO resolves to the fallback would + * inherit the stale workflow's persistent block (a cross-session false + * positive). Both hooks therefore refuse to write — and refuse to act on — + * persistent state under this id. + */ +export const UNKNOWN_SESSION_ID = "unknown"; diff --git a/.agents/hooks/core/hook-output.ts b/.agents/hooks/core/hook-output.ts index d9ab4a3..306fecd 100644 --- a/.agents/hooks/core/hook-output.ts +++ b/.agents/hooks/core/hook-output.ts @@ -1,5 +1,5 @@ // Vendor-specific hook output builders. -// Each runtime (Claude Code, Codex CLI, Cursor, Gemini CLI, Qwen Code) +// Each runtime (Claude Code, Codex CLI, Cursor, Qwen Code) // expects a slightly different stdout JSON shape; centralize the dialect // translation here so individual hooks can stay vendor-agnostic. @@ -8,6 +8,11 @@ import type { Vendor } from "./types.ts"; export function makePromptOutput( vendor: Vendor, additionalContext: string, + // Native hook event the context is injected for. Defaults to the prompt-submit + // event; the dispatch layer passes "SessionStart" for session-start injection + // (commandcode / cursor sessionStart) so the emitted hookSpecificOutput names + // the correct event. Standalone core-script callers use the default. + hookEventName: string = "UserPromptSubmit", ): string { switch (vendor) { case "antigravity": @@ -19,40 +24,43 @@ export function makePromptOutput( injectSteps: [{ ephemeralMessage: additionalContext }], }); case "claude": - case "commandcode": + case "commandcode": { // Official Claude Code docs (code.claude.com/docs/en/hooks) specify // `hookSpecificOutput.additionalContext` — the top-level field is kept // for back-compat with older builds that read it. // commandcode (Command Code, commandcode.ai) mirrors the Claude hook - // dialect, but has NO prompt event (only PreToolUse/PostToolUse/Stop), - // so this branch never fires for it — kept for Vendor exhaustiveness. - return JSON.stringify({ + // dialect. It has no prompt-submit event, but DOES inject context on + // SessionStart (additionalContext) — dispatch passes hookEventName + // "SessionStart" for that path. + const hookSpecificOutput: Record = { + hookEventName, additionalContext, - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext, - }, - }); + }; + // Claude Code re-scans skill/command directories after SessionStart hooks + // complete when the output sets `reloadSkills` (docs: SessionStart + // hookSpecificOutput.reloadSkills). It is Claude-only and only meaningful + // for SessionStart; this builder is called solely when context was + // actually injected, so the "only when injecting" condition is inherent. + if (vendor === "claude" && hookEventName === "SessionStart") { + hookSpecificOutput.reloadSkills = true; + } + return JSON.stringify({ additionalContext, hookSpecificOutput }); + } case "codex": return JSON.stringify({ hookSpecificOutput: { - hookEventName: "UserPromptSubmit", + hookEventName, additionalContext, }, }); case "cursor": + // Cursor reads the top-level `additional_context` (sessionStart) / + // `additionalContext`; the hookSpecificOutput block is informational. return JSON.stringify({ additionalContext, additional_context: additionalContext, hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext, - }, - }); - case "gemini": - return JSON.stringify({ - hookSpecificOutput: { - hookEventName: "BeforeAgent", + hookEventName, additionalContext, }, }); @@ -64,6 +72,10 @@ export function makePromptOutput( case "kiro": // Kiro CLI adds stdout directly to the agent context for prompt hooks. return additionalContext; + case "kimi": + // Kimi Code CLI: a blockable hook that exits 0 has its stdout appended to + // the model context (kimi.com/code/docs hooks). Plain text injects directly. + return additionalContext; case "pi": // pi (Earendil) reads this via the in-process bridge in // `.pi/extensions/oma/index.ts`, which lifts `additionalContext` into the @@ -73,7 +85,7 @@ export function makePromptOutput( // Qwen Code fork uses hookSpecificOutput (same as Codex) return JSON.stringify({ hookSpecificOutput: { - hookEventName: "UserPromptSubmit", + hookEventName, additionalContext, }, }); @@ -85,26 +97,46 @@ export function makeBlockOutput(vendor: Vendor, reason: string): string { case "claude": case "codex": case "commandcode": - case "cursor": case "kiro": case "qwen": return JSON.stringify({ decision: "block", reason }); + case "cursor": + // Cursor's `stop` hook ignores Claude-style `{decision:"block"}`. It + // re-enters the loop via `{followup_message}`, which is auto-submitted as + // the next turn (capped by the entry's loop_limit). Cursor's only + // block-producing chain is `stop` — its sole preToolUse handler + // (test-filter) never blocks, only mutates — so followup_message is + // always the correct dialect here. + return JSON.stringify({ followup_message: reason }); case "antigravity": // agy Stop: `decision:"continue"` re-enters the loop (= block the stop); // `reason` is injected as a system message. (Any other value allows stop.) return JSON.stringify({ decision: "continue", reason }); - case "gemini": - // Gemini AfterAgent uses "deny" to reject response and force retry - return JSON.stringify({ decision: "deny", reason }); case "pi": - // pi has no stop-blocking event (agent_end is notification-only), so - // persistent-mode never runs under pi. This shape mirrors pi's native - // tool_call block return for completeness/forward-compat. + // pi's bridge implements persistent-mode via agent_settled + + // pi.sendUserMessage: it runs the persistent-mode subprocess (which + // resolves to the Claude dialect `{decision:"block", reason}`) and also + // accepts this `{block:true, reason}` shape, re-submitting `reason` as the + // next turn so the workflow continues. return JSON.stringify({ block: true, reason }); case "grok": // Grok Stop hooks are generally advisory. Emit block decision + rich // stderr message (persistent-mode already prints the reason to stderr). return JSON.stringify({ decision: "block", reason }); + case "kimi": + // Kimi documents two blocking mechanisms: exit 2 + stderr, and a JSON + // `hookSpecificOutput.permissionDecision: "deny"` response. The oma hook + // router always exits 0 and writes the dialect to stdout, so we emit the + // JSON form. We also include the Claude-style `{decision:"block"}` keys so + // whichever shape Kimi's Stop handler honours, persistent-mode re-enters. + return JSON.stringify({ + decision: "block", + reason, + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }); } } @@ -113,16 +145,6 @@ export function makePreToolOutput( updatedInput: Record, ): string { switch (vendor) { - case "gemini": - // Official BeforeTool rewrite contract (geminicli.com/docs/hooks/reference): - // `hookSpecificOutput.tool_input` merges with and overrides the model's - // arguments. There is no "rewrite" decision value (only allow/deny). - return JSON.stringify({ - hookSpecificOutput: { - hookEventName: "BeforeTool", - tool_input: updatedInput, - }, - }); case "cursor": return JSON.stringify({ updated_input: updatedInput, @@ -134,6 +156,7 @@ export function makePreToolOutput( case "claude": case "codex": case "commandcode": + case "kimi": case "kiro": case "qwen": // Codex requires `permissionDecision: "allow"` alongside `updatedInput` diff --git a/.agents/hooks/core/hud.ts b/.agents/hooks/core/hud.ts index 429cd29..6850e7c 100644 --- a/.agents/hooks/core/hud.ts +++ b/.agents/hooks/core/hud.ts @@ -2,45 +2,19 @@ /** * oh-my-agent — HUD * - * Lightweight status display. Two modes: - * - * - Claude Code / agy (statusLine): stdin = vendor payload, stdout = ANSI - * text consumed by the native status-line renderer. Field names line up - * across both vendors; vendor-specific extras are best-effort. - * - Gemini CLI (SessionStart, AfterTool, AfterAgent hooks): stdin = Gemini - * hook payload, stdout = `{}` (protocol no-op), side effect = best-effort - * bottom-row bar written to /dev/tty. - * - * Vendor is inferred from the installed script path: - * `.gemini/hooks/` → gemini bar mode - * `.claude/hooks/` or `.gemini/antigravity-cli/hooks/` → claude statusline mode + * Lightweight status display for Claude Code / agy (statusLine): stdin = + * vendor payload, stdout = ANSI text consumed by the native status-line + * renderer. Field names line up across both vendors; vendor-specific extras + * are best-effort. * * Set `OMA_HUD_DEBUG=1` to dump the raw stdin payload to * `/../last-hud-input.json` for schema reverse-engineering. */ -import { - closeSync, - existsSync, - openSync, - readdirSync, - readFileSync, - writeFileSync, - writeSync, -} from "node:fs"; -import { join, sep } from "node:path"; +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { ModeState } from "./types.ts"; -type HudVendor = "claude" | "gemini"; - -function inferVendor(): HudVendor { - const path = import.meta.filename ?? ""; - // Strict match on `/.gemini/hooks/` so agy (`/.gemini/antigravity-cli/hooks/`) - // falls through to claude — agy's StatusLine uses Claude's stdout protocol. - if (path.includes(`${sep}.gemini${sep}hooks${sep}`)) return "gemini"; - return "claude"; -} - // ── ANSI Colors ─────────────────────────────────────────────── const dim = (s: string) => `\x1b[2m${s}\x1b[22m`; @@ -105,16 +79,6 @@ interface StatuslineStdin { terminal_width?: number; } -interface GeminiHookInput { - hook_event_name?: string; - cwd?: string; - tool_name?: string; - tool_response?: { exit_code?: number; success?: boolean } | unknown; - prompt?: string; - prompt_response?: string; - source?: string; -} - function readStdin(): StatuslineStdin { const raw = (() => { try { @@ -131,22 +95,6 @@ function readStdin(): StatuslineStdin { } } -function readRaw(): unknown { - const raw = (() => { - try { - return readFileSync(0, "utf-8"); - } catch { - return ""; - } - })(); - maybeDumpDebugPayload(raw); - try { - return JSON.parse(raw); - } catch { - return {}; - } -} - /** * When `OMA_HUD_DEBUG=1`, capture the raw stdin payload to a sibling file so * vendor-specific schemas can be reverse-engineered (notably agy's StatusLine, @@ -235,67 +183,6 @@ function formatTokens(n: number): string { return `${(n / 1_000_000).toFixed(1)}M`; } -// ── Gemini bar (DECSTBM-free best-effort bottom row) ────────── - -/** - * Write `line` to the controlling TTY at the bottom row, then restore the - * cursor so the host CLI's render is not visibly disturbed. Best-effort: - * silently no-ops in environments without /dev/tty (CI, piped runs); when - * the host CLI repaints, the bar is overwritten until the next event. - * Intentionally avoids DECSTBM scroll-region changes so scrollback survives - * a mid-write hook kill. - */ -function paintBottomBar(line: string): void { - let fd: number | null = null; - try { - fd = openSync("/dev/tty", "w"); - const SAVE = "\x1b7"; // DECSC: save cursor + attrs - const RESTORE = "\x1b8"; // DECRC: restore cursor + attrs - const TO_BOTTOM = "\x1b[999;1H"; // clamp to last row, col 1 - const CLEAR_LINE = "\x1b[2K"; - writeSync(fd, `${SAVE}${TO_BOTTOM}${CLEAR_LINE}\r${line}${RESTORE}`); - } catch { - // No tty available — silent. - } finally { - if (fd !== null) { - try { - closeSync(fd); - } catch { - // ignore close failures - } - } - } -} - -export function buildGeminiBar( - input: GeminiHookInput, - projectDir: string, -): string { - const parts: string[] = [bold(cyan("[OMA]"))]; - - const event = input.hook_event_name; - if (event) parts.push(dim(event)); - - const workflow = getActiveWorkflow(projectDir); - if (workflow) { - parts.push(yellow(`${workflow.workflow}:${workflow.reinforcementCount}`)); - } - - if (input.tool_name) { - const resp = input.tool_response as { exit_code?: number } | undefined; - const failed = typeof resp?.exit_code === "number" && resp.exit_code !== 0; - const label = `tool:${input.tool_name}`; - parts.push(failed ? red(label) : green(label)); - } - - const now = new Date(); - const hh = String(now.getHours()).padStart(2, "0"); - const mm = String(now.getMinutes()).padStart(2, "0"); - parts.push(dim(`${hh}:${mm}`)); - - return parts.join(dim(" │ ")); -} - // ── Claude / agy statusline ─────────────────────────────────── export function buildClaudeStatusline(input: StatuslineStdin): string { @@ -397,21 +284,6 @@ export function buildClaudeStatusline(input: StatuslineStdin): string { // ── Main ────────────────────────────────────────────────────── function main() { - const vendor = inferVendor(); - - if (vendor === "gemini") { - const raw = readRaw() as GeminiHookInput; - const projectDir = - process.env.GEMINI_PROJECT_DIR || - process.env.ANTIGRAVITY_PROJECT_DIR || - raw.cwd || - process.cwd(); - paintBottomBar(buildGeminiBar(raw, projectDir)); - // Gemini hook protocol: empty object = no-op, do not influence the agent. - process.stdout.write("{}"); - return; - } - process.stdout.write(buildClaudeStatusline(readStdin())); } diff --git a/.agents/hooks/core/keyword-detector.ts b/.agents/hooks/core/keyword-detector.ts index 35deadf..e336814 100644 --- a/.agents/hooks/core/keyword-detector.ts +++ b/.agents/hooks/core/keyword-detector.ts @@ -2,7 +2,7 @@ /** * oh-my-agent — Prompt Hook (keyword detection) * - * Works with: Claude Code (UserPromptSubmit), Codex CLI (UserPromptSubmit), Gemini CLI (BeforeAgent) + * Works with: Claude Code (UserPromptSubmit), Codex CLI (UserPromptSubmit), and the other host CLIs in VENDORS * * Detects natural-language keywords in user prompts and injects * workflow instructions into the agent's context. @@ -21,16 +21,11 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import { - agyConversationId, - agyProjectDir, - isAgyInput, - readAgyPrompt, -} from "./agy-input.ts"; -import { VENDORS } from "./constants.ts"; -import { resolveGitRoot } from "./fs-utils.ts"; +import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; +import { UNKNOWN_SESSION_ID, VENDORS } from "./constants.ts"; import { clearGrokContext } from "./grok-context.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { isRelayedAgentMessage, normalizePromptInput } from "./prompt-input.ts"; // triggers.json is imported statically: the bundler inlines it into the oma // binary (bundled `oma hook` path needs no file on disk), while a standalone // bun run resolves the sibling file next to this module (pi / direct run). @@ -42,6 +37,7 @@ import type { ModeState, Vendor, } from "./types.ts"; +import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; // ── Unicode normalization ───────────────────────────────────── @@ -62,7 +58,7 @@ export function normalizeForMatching(text: string): string { /** * Brands that count as CLI invocations: Oma plus the host LLM CLIs declared - * in `VENDORS` (claude, codex, cursor, gemini, qwen). The vendor list is + * in `VENDORS` (claude, codex, cursor, qwen, …). The vendor list is * the single source of truth for hook-supported runtimes; pulling from it * here keeps the brand set in sync when a new vendor is added. * @@ -103,8 +99,7 @@ const SIGNALS_RE_SOURCE = CLI_INVOCATION_SIGNALS.join("|"); * enumerated subcommand verbs (agent / auto / exec / run / spawn), * a --flag, or a colon-namespaced subcommand ('agent:spawn'). * Examples: 'oma agent:spawn brainstorm', 'claude --help', - * 'codex exec --workflow ralph', 'gemini agent', 'cursor agent', - * 'qwen run'. + * 'codex exec --workflow ralph', 'cursor agent', 'qwen run'. */ export const CLI_INVOCATION_AT_START = new RegExp( `^\\s*(?:\\/(?:${BRANDS_RE_SOURCE}):|(?:${BRANDS_RE_SOURCE})\\s+(?:${SIGNALS_RE_SOURCE}))`, @@ -143,7 +138,6 @@ const VALID_USER_EVENTS = new Set([ "user_prompt_submit", // Grok "userPromptSubmit", // Kiro "beforeSubmitPrompt", // Cursor - "BeforeAgent", // Gemini (fires before agent processes user prompt) "PreInvocation", // Antigravity CLI (agy) ]); @@ -161,6 +155,11 @@ export function isGenuineUserPrompt(input: Record): boolean { return true; } +// ── Guard: relayed inter-agent messages ────────────────────── +// Shared with skill-injector — see prompt-input.ts. Re-exported here so +// existing imports/tests keep resolving from this module. +export { isRelayedAgentMessage }; + // ── Guard 3: Reinforcement suppression ─────────────────────── const REINFORCEMENT_WINDOW_MS = 60_000; // 60 seconds @@ -273,24 +272,10 @@ export function recordKwTrigger( // ── Vendor Detection ────────────────────────────────────────── -function inferVendorFromScriptPath(): Vendor | null { - const path = import.meta.filename; - if (path.includes(`${join(".gemini", "antigravity-cli", "hooks")}`)) - return "antigravity"; - if (path.includes(`${join(".cursor", "hooks")}`)) return "cursor"; - if (path.includes(`${join(".qwen", "hooks")}`)) return "qwen"; - if (path.includes(`${join(".claude", "hooks")}`)) return "claude"; - if (path.includes(`${join(".gemini", "hooks")}`)) return "gemini"; - if (path.includes(`${join(".codex", "hooks")}`)) return "codex"; - if (path.includes(`${join(".grok", "hooks")}`)) return "grok"; - if (path.includes(`${join(".kiro", "hooks")}`)) return "kiro"; - return null; -} - function detectVendor(input: Record): Vendor { const event = input.hook_event_name as string | undefined; const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(); + const byScriptPath = inferVendorFromScriptPath(import.meta.filename); if (byScriptPath) return byScriptPath; // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. @@ -311,7 +296,6 @@ function detectVendor(input: Record): Vendor { } if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; if (event === "beforeSubmitPrompt") return "cursor"; if (event === "UserPromptSubmit") { // Codex uses snake_case session_id, Claude uses camelCase sessionId @@ -322,51 +306,12 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - case "cursor": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "antigravity": - dir = - agyProjectDir(input) || - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.env.GEMINI_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - function getSessionId(input: Record): string { return ( (input.sessionId as string) || (input.session_id as string) || agyConversationId(input) || - "unknown" + UNKNOWN_SESSION_ID ); } @@ -410,24 +355,84 @@ export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Merge a language-keyed keyword/pattern bank into a single flat list: + * universal ("*") + English (the universal default) + the configured + * language's own entries (skipped when lang === "en" to avoid duplicates). + * Shared by buildPatterns and buildRawPatterns — both keyword banks and + * pattern banks use this exact `Record` shape. + */ +export function collectLangEntries( + bank: Record, + lang: string, +): string[] { + return [ + ...(bank["*"] ?? []), + ...(bank.en ?? []), + ...(lang !== "en" ? (bank[lang] ?? []) : []), + ]; +} + +/** + * Keyword-plus-compiled-regex pair. Kept 1:1 with the literal keyword string + * (as authored in triggers.json) so callers that need the ACTUAL matched + * keyword text — e.g. specificity ranking — don't have to re-derive it from + * match[0], which would require re-deriving buildPatterns' word-boundary + * peeling logic (fragile, and wrong for CJK where no boundary is added). + */ +export interface KeywordPatternEntry { + regex: RegExp; + keyword: string; +} + +export function buildPatternEntries( + keywords: Record, + lang: string, + cjkScripts: string[], +): KeywordPatternEntry[] { + return collectLangEntries(keywords, lang).map((kw) => { + const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); + const regex = + cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw) + ? new RegExp(escaped, "i") + : new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); + return { regex, keyword: kw }; + }); +} + export function buildPatterns( keywords: Record, lang: string, cjkScripts: string[], ): RegExp[] { - const allKeywords = [ - ...(keywords["*"] ?? []), - ...(keywords.en ?? []), - ...(lang !== "en" ? (keywords[lang] ?? []) : []), - ]; + return buildPatternEntries(keywords, lang, cjkScripts).map((e) => e.regex); +} - return allKeywords.map((kw) => { - const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); - if (cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw)) { - return new RegExp(escaped, "i"); +/** + * Raw-pattern-plus-source pair — mirrors KeywordPatternEntry for the + * `patterns` (intent regex) field. `source` is the raw regex string itself: + * unlike keyword entries, a raw pattern has no fixed "keyword" — its + * specificity is however much text it actually matched (match[0]). + */ +export interface RawPatternEntry { + regex: RegExp; + source: string; +} + +export function buildRawPatternEntries( + patterns: Record | undefined, + lang: string, +): RawPatternEntry[] { + if (!patterns) return []; + const compiled: RawPatternEntry[] = []; + for (const raw of collectLangEntries(patterns, lang)) { + try { + compiled.push({ regex: new RegExp(raw, "iu"), source: raw }); + } catch { + // Skip invalid regex — surfaces during config edit, not at runtime } - return new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); - }); + } + return compiled; } /** @@ -440,21 +445,7 @@ export function buildRawPatterns( patterns: Record | undefined, lang: string, ): RegExp[] { - if (!patterns) return []; - const all = [ - ...(patterns["*"] ?? []), - ...(patterns.en ?? []), - ...(lang !== "en" ? (patterns[lang] ?? []) : []), - ]; - const compiled: RegExp[] = []; - for (const raw of all) { - try { - compiled.push(new RegExp(raw, "iu")); - } catch { - // Skip invalid regex — surfaces during config edit, not at runtime - } - } - return compiled; + return buildRawPatternEntries(patterns, lang).map((e) => e.regex); } export function buildInformationalPatterns(config: TriggerConfig): RegExp[] { @@ -717,6 +708,11 @@ function activateMode( workflow: string, sessionId: string, ): void { + // Never persist a workflow under the unresolved-session fallback id: such a + // file cannot be isolated per session and would cross-contaminate any later + // session that also resolves to UNKNOWN_SESSION_ID. The workflow context is + // still injected by the caller — it just won't be enforced across stops. + if (sessionId === UNKNOWN_SESSION_ID) return; const state: ModeState = { workflow, sessionId, @@ -808,6 +804,93 @@ export function deactivateAllPersistentModes( } } +// ── Specificity ranking ─────────────────────────────────────── + +/** + * One surviving pattern match for one workflow, carrying everything the + * ranking rules in `pickWinningCandidate` need. Built once per successful + * `pattern.exec()` across ALL workflows in `config.workflows` — the matching + * loop no longer returns on the first hit; it collects every candidate + * first and lets specificity decide the winner. + */ +export interface WorkflowCandidate { + workflow: string; + persistent: boolean; + /** Index of the match within the cleaned (stripped/normalized) text. */ + matchIndex: number; + matchText: string; + /** Position re-located in the ORIGINAL prompt — used for tie-break #3. */ + origIndex: number; + /** Length of the specific text that matched (trimmed keyword or, for a + * `patterns` intent-regex hit, the trimmed match[0] span) — rule #1. */ + keywordLength: number; + /** Whether the specificity text contains whitespace — rule #2. */ + isMultiWord: boolean; + /** Index of this workflow in triggers.json `workflows` — rule #4 (final + * tiebreak, preserves the pre-ranking first-declared-wins behavior). */ + declarationIndex: number; + /** True if any suppression filter (RC3 technical-reference is a hard drop + * and never reaches this point; informational-context / pasted-content / + * reinforcement) applies to this specific match. */ + suppressed: boolean; +} + +/** + * Pick the winning candidate among all workflow matches collected for a + * prompt, or `null` if none survive. + * + * Ranking order (only consulted on a tie with the previous rule): + * 1. Longest matched keyword/phrase wins — "deepsec pr review" (18 chars) + * beats "review" (6 chars) even though "review" also literally matches + * as a substring of the same sentence. + * 2. A multi-word/compound match beats a single-word match of equal + * length (defensive tiebreak; rule 1 already separates most real + * cases since compound phrases are almost always longer). + * 3. Earliest match position in the ORIGINAL prompt wins — mirrors the + * existing "keyword near the front = command position" heuristic used + * elsewhere in this file (RC2 pasted-content guard). + * 4. Final tiebreak: declaration order in triggers.json `workflows` — + * i.e. the original pre-ranking first-match-wins behavior, kept only + * as a last resort when two workflows are otherwise indistinguishable. + * + * DESIGN DECISION (round-1 trigger demotion, part A): suppression is + * evaluated PER CANDIDATE, not globally. A candidate flagged `suppressed` + * (by the informational-context window, the persistent-mode pasted-content + * limit, or reinforcement) is simply removed from the ranking pool — it + * does NOT veto a *different*, unsuppressed candidate from a more generic + * workflow. Reasoning: every existing suppression filter in this file + * already operates locally, on one match at a time (a suppressed hit in one + * workflow's pattern loop has always fallen through to the next + * pattern/workflow, never blocking unrelated matches elsewhere in the same + * prompt) — ranking preserves that locality instead of upgrading it into a + * prompt-wide veto. The alternative (any suppressed specific match blocks + * the whole prompt from firing anything) would silence genuine, independent + * requests that merely share a sentence with a meta-mention of a more + * specific workflow. See triggers-corpus.json for a case that locks this in: + * a prompt that asks an informational "what is X" question about a specific + * workflow AND, separately, makes a genuine generic request — the generic + * request still fires. + */ +export function pickWinningCandidate( + candidates: WorkflowCandidate[], +): WorkflowCandidate | null { + const eligible = candidates.filter((c) => !c.suppressed); + if (eligible.length === 0) return null; + eligible.sort((a, b) => { + if (b.keywordLength !== a.keywordLength) { + return b.keywordLength - a.keywordLength; + } + if (a.isMultiWord !== b.isMultiWord) { + return a.isMultiWord ? -1 : 1; + } + if (a.origIndex !== b.origIndex) { + return a.origIndex - b.origIndex; + } + return a.declarationIndex - b.declarationIndex; + }); + return eligible[0] ?? null; +} + // ── Pure handler (canonical ABI) ───────────────────────────── /** @@ -834,6 +917,9 @@ export async function run( if (!prompt.trim()) return null; if (startsWithSlashCommand(prompt)) return null; + // Relayed inter-agent messages carry another agent's text, not a user + // request — their content must not drive workflow keyword detection. + if (isRelayedAgentMessage(prompt)) return null; const config = loadConfig(); const lang = detectLanguage(projectDir); @@ -860,73 +946,125 @@ export async function run( // Skip persistent workflows entirely if the prompt is an analytical question const analytical = isAnalyticalQuestion(cleaned); - for (const [workflow, def] of Object.entries(config.workflows)) { + // shouldSkipAllWorkflows does not depend on the workflow being evaluated — + // hoisted out of the loop (was re-checked on every iteration pre-ranking). + if (shouldSkipAllWorkflows(cleaned)) return null; + + // Position guard must reflect the user's ACTUAL prompt, not the + // content-stripped text. stripCodeBlocks/stripSystemEchoes remove quoted + // and code spans, which shrinks the text and pulls keywords toward the + // front — defeating the "deep in a long prompt = not an instruction" + // heuristic (a keyword genuinely at char 245 of a discussion can appear + // at char 179 after stripping, slipping under PERSISTENT_MATCH_LIMIT). + const origPrompt = normalizeForMatching(prompt); + + // Collect every surviving match across every workflow first — specificity + // ranking (see pickWinningCandidate) decides the winner, replacing the old + // declaration-order first-match-wins loop. + const candidates: WorkflowCandidate[] = []; + const workflowEntries = Object.entries(config.workflows); + + for ( + let declarationIndex = 0; + declarationIndex < workflowEntries.length; + declarationIndex++ + ) { + const entry = workflowEntries[declarationIndex]; + if (!entry) continue; + const [workflow, def] = entry; if (excluded.has(workflow)) continue; - if (shouldSkipAllWorkflows(cleaned)) continue; const workflowPredicate = KEYWORD_SKIP_PREDICATES[workflow]; if (workflowPredicate?.(cleaned)) continue; if (analytical && def.persistent) continue; - const patterns = [ - ...buildPatterns(def.keywords, lang, config.cjkScripts), - ...buildRawPatterns(def.patterns, lang), - ]; + const reinforced = isReinforcementSuppressed(kwState, workflow); - for (const pattern of patterns) { - const match = pattern.exec(cleaned); - if (!match) continue; + const considerMatch = (regex: RegExp, specificityText: string) => { + const match = regex.exec(cleaned); + if (!match) return; // RC3: compound technical tokens (ralph:verify, ralph.md, // workflows/ralph) reference the workflow as an artifact, not a run - // request. - if (isTechnicalReference(cleaned, match.index, match[0])) continue; - if (isInformationalContext(cleaned, match.index, infoPatterns)) continue; - // Position guard must reflect the user's ACTUAL prompt, not the - // content-stripped text. stripCodeBlocks/stripSystemEchoes remove quoted - // and code spans, which shrinks the text and pulls keywords toward the - // front — defeating the "deep in a long prompt = not an instruction" - // heuristic (a keyword genuinely at char 245 of a discussion can appear - // at char 179 after stripping, slipping under PERSISTENT_MATCH_LIMIT). - // Re-locate the matched keyword in the original prompt for the check. - const origPrompt = normalizeForMatching(prompt); + // request — dropped entirely, never becomes a candidate. + if (isTechnicalReference(cleaned, match.index, match[0])) return; + + // Re-locate the matched keyword in the original prompt for the + // pasted-content position guard. const origIndex = origPrompt.indexOf(match[0]); const posIndex = origIndex >= 0 ? origIndex : match.index; - if (isPastedContent(posIndex, def.persistent, origPrompt.length)) - continue; - if (isReinforcementSuppressed(kwState, workflow)) continue; + const informational = isInformationalContext( + cleaned, + match.index, + infoPatterns, + ); + const pasted = isPastedContent( + posIndex, + def.persistent, + origPrompt.length, + ); + const text = specificityText.trim(); + + candidates.push({ + workflow, + persistent: def.persistent, + matchIndex: match.index, + matchText: match[0], + origIndex: posIndex, + keywordLength: text.length, + isMultiWord: /\s/.test(text), + declarationIndex, + suppressed: informational || pasted || reinforced, + }); + }; + + for (const { regex, keyword } of buildPatternEntries( + def.keywords, + lang, + config.cjkScripts, + )) { + considerMatch(regex, keyword); + } + for (const { regex, source } of buildRawPatternEntries( + def.patterns, + lang, + )) { + considerMatch(regex, source); + } + } - if (def.persistent) { - activateMode(projectDir, workflow, sessionId); - } - await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); - const updatedState = recordKwTrigger(kwState, workflow); - saveKwState(projectDir, updatedState); - - const contextLines = [ - `[OMA WORKFLOW: ${workflow.toUpperCase()}]`, - `User intent matches the /${workflow} workflow.`, - `Read and follow \`.agents/workflows/${workflow}.md\` step by step.`, - `User request: ${prompt}`, - `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, - ]; - - if (config.extensionRouting) { - const extensions = detectExtensions(prompt); - const agent = resolveAgentFromExtensions( - extensions, - config.extensionRouting, - ); - if (agent) { - contextLines.push(`[OMA AGENT HINT: ${agent}]`); - } - } + const winner = pickWinningCandidate(candidates); + if (!winner) return null; + + const { workflow } = winner; + + if (winner.persistent) { + activateMode(projectDir, workflow, sessionId); + } + await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); + const updatedState = recordKwTrigger(kwState, workflow); + saveKwState(projectDir, updatedState); + + const contextLines = [ + `[OMA WORKFLOW: ${workflow.toUpperCase()}]`, + `User intent matches the /${workflow} workflow.`, + `Read and follow \`.agents/workflows/${workflow}.md\` step by step.`, + `User request: ${prompt}`, + `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, + ]; - return { type: "context", additionalContext: contextLines.join("\n") }; + if (config.extensionRouting) { + const extensions = detectExtensions(prompt); + const agent = resolveAgentFromExtensions( + extensions, + config.extensionRouting, + ); + if (agent) { + contextLines.push(`[OMA AGENT HINT: ${agent}]`); } } - return null; + return { type: "context", additionalContext: contextLines.join("\n") }; } // ── Standalone entry (pi subprocess / direct bun invocation) ── @@ -946,7 +1084,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt` — recover the user request // from the transcript. PreInvocation fires before every model call, so only diff --git a/.agents/hooks/core/persistent-mode.ts b/.agents/hooks/core/persistent-mode.ts index 9e434d9..e20dda6 100644 --- a/.agents/hooks/core/persistent-mode.ts +++ b/.agents/hooks/core/persistent-mode.ts @@ -2,7 +2,7 @@ /** * oh-my-agent — Stop Hook (Persistent Mode) * - * Works with: Claude Code (Stop), Codex CLI (Stop), Gemini CLI (AfterAgent) + * Works with: Claude Code (Stop), Codex CLI (Stop) * * Prevents the agent from stopping while a long-running workflow * (ultrawork, orchestrate, work) is active. @@ -21,8 +21,8 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import { agyConversationId, agyProjectDir, isAgyInput } from "./agy-input.ts"; -import { resolveGitRoot } from "./fs-utils.ts"; +import { agyConversationId, isAgyInput } from "./agy-input.ts"; +import { UNKNOWN_SESSION_ID } from "./constants.ts"; import { makeBlockOutput } from "./hook-output.ts"; import { isDeactivationRequest } from "./keyword-detector.ts"; // triggers.json is imported statically: bundler inlines it into the oma binary; @@ -35,6 +35,7 @@ import type { ModeState, Vendor, } from "./types.ts"; +import { getProjectDir } from "./vendor-detect.ts"; const MAX_REINFORCEMENTS = 5; const STALE_HOURS = 2; @@ -90,7 +91,6 @@ function detectVendor(input: Record): Vendor { if (isAgyInput(input)) return "antigravity"; if (event === "Stop" && process.env.ANTIGRAVITY_PROJECT_DIR) return "antigravity"; - if (event === "AfterAgent") return "gemini"; if (event === "Stop") { if ("session_id" in input && !("sessionId" in input)) return "codex"; } @@ -98,50 +98,12 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "antigravity": - dir = - agyProjectDir(input) || - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.env.GEMINI_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - function getSessionId(input: Record): string { return ( (input.sessionId as string) || (input.session_id as string) || agyConversationId(input) || - "unknown" + UNKNOWN_SESSION_ID ); } @@ -241,7 +203,16 @@ export async function run( ): Promise { if (input.kind !== "stop") return null; - const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; + const { cwd: projectDir, sid: sessionId = UNKNOWN_SESSION_ID } = ctx; + + // A stop event whose session id resolves to the fallback cannot be isolated: + // blocking on an `-unknown` state file would freeze unrelated sessions that + // also lack a resolvable id. Sweep any such orphan files (they should no + // longer be created — see activateMode) and never block under this id. + if (sessionId === UNKNOWN_SESSION_ID) { + deactivateAllForSession(projectDir, UNKNOWN_SESSION_ID); + return null; + } // Honor "workflow done" deactivation carried in the stop payload's response // text (parity with the standalone main() path). Without this, persistent @@ -304,7 +275,7 @@ async function main() { // This raw-stdin check is standalone-path-only; the canonical HookInput // { kind: "stop" } does not carry these text fields. const textToCheck = [ - input.prompt_response, // Gemini AfterAgent + input.prompt_response, input.response, input.content, input.message, diff --git a/.agents/hooks/core/prompt-input.ts b/.agents/hooks/core/prompt-input.ts new file mode 100644 index 0000000..7f18580 --- /dev/null +++ b/.agents/hooks/core/prompt-input.ts @@ -0,0 +1,45 @@ +// Shared normalizer for the raw `prompt` field delivered on hook stdin. +// +// Most host CLIs deliver `prompt` as a plain string, but some (e.g. Kimi Code +// CLI's UserPromptSubmit payload) deliver it as a ContentPart[] array such as +// `[{ "type": "text", "text": "hello" }]`. The standalone hook scripts cast it +// with `(input.prompt as string) ?? ""`, so on those vendors the downstream +// string methods throw and the top-level catch swallows the error — the entire +// prompt chain silently no-ops. Route every raw read through this helper so the +// array shape collapses to the equivalent string. + +// Relayed inter-agent messages (teammate reports, task notifications, idle +// notifications) are transported through the prompt channel but are not user +// intent: their content routinely carries workflow keywords and skill trigger +// words, producing false activations. Detect the transport envelopes +// conservatively so prompt-driven handlers can skip them. +const RELAY_ENVELOPE_PREFIXES = [ + " trimmed.startsWith(p))) return true; + return trimmed.slice(0, 200).includes('"type":"idle_notification"'); +} + +/** Coerce a raw stdin `prompt` field to a string across vendor payload shapes. */ +export function normalizePromptInput(prompt: unknown): string { + if (typeof prompt === "string") return prompt; + if (Array.isArray(prompt)) { + return prompt + .filter( + (p): p is { type: string; text: string } => + !!p && + typeof p === "object" && + (p as { type?: unknown }).type === "text" && + typeof (p as { text?: unknown }).text === "string", + ) + .map((p) => p.text) + .join(" "); + } + return ""; +} diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts index d4f321d..df2aabc 100644 --- a/.agents/hooks/core/serena-primer.ts +++ b/.agents/hooks/core/serena-primer.ts @@ -2,7 +2,7 @@ /** * oh-my-agent — Serena Primer Hook (prompt kind) * - * Works with: Claude Code, Codex CLI, Cursor, Gemini CLI, Qwen Code, + * Works with: Claude Code, Codex CLI, Cursor, Qwen Code, * Antigravity, Grok, Kiro. * * Serena ships per-vendor context prompts that say "prefer Serena's symbolic @@ -23,15 +23,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { - agyConversationId, - agyProjectDir, - isAgyInput, - readAgyPrompt, -} from "./agy-input.ts"; -import { resolveGitRoot } from "./fs-utils.ts"; +import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; const SESSION_TTL_MS = 60 * 60 * 1000; @@ -121,6 +117,7 @@ export function primerContext(): string { "- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.", "- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.", "- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.", + "- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.", ].join("\n"); } @@ -140,30 +137,18 @@ export async function run( const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; if (!isSerenaProject(projectDir)) return null; - if (!claimSession(projectDir, sessionId)) return null; + // Compaction keeps the session id, so the session-once claim would skip + // exactly the turn that just lost the primer from context — force re-inject. + const forced = input.source === "compact"; + if (!claimSession(projectDir, sessionId) && !forced) return null; return { type: "context", additionalContext: primerContext() }; } // ── Standalone entry (pi subprocess / direct bun invocation) ── -function inferVendorFromScriptPath(): Vendor | null { - const path = import.meta.filename; - if (path.includes(`${join(".gemini", "antigravity-cli", "hooks")}`)) - return "antigravity"; - if (path.includes(`${join(".cursor", "hooks")}`)) return "cursor"; - if (path.includes(`${join(".qwen", "hooks")}`)) return "qwen"; - if (path.includes(`${join(".claude", "hooks")}`)) return "claude"; - if (path.includes(`${join(".gemini", "hooks")}`)) return "gemini"; - if (path.includes(`${join(".codex", "hooks")}`)) return "codex"; - if (path.includes(`${join(".grok", "hooks")}`)) return "grok"; - if (path.includes(`${join(".kiro", "hooks")}`)) return "kiro"; - if (path.includes(`${join(".pi", "extensions")}`)) return "pi"; - return null; -} - function detectVendor(input: Record): Vendor { - const byScriptPath = inferVendorFromScriptPath(); + const byScriptPath = inferVendorFromScriptPath(import.meta.filename); if (byScriptPath) return byScriptPath; if (isAgyInput(input)) return "antigravity"; const event = input.hook_event_name as string | undefined; @@ -177,7 +162,6 @@ function detectVendor(input: Record): Vendor { return "kiro"; } if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; if (event === "beforeSubmitPrompt") return "cursor"; if ( event === "UserPromptSubmit" && @@ -189,45 +173,6 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - case "cursor": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "antigravity": - dir = - agyProjectDir(input) || - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.env.GEMINI_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - function getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -249,7 +194,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt`; recover it and only act on // the first invocation of a turn. diff --git a/.agents/hooks/core/skill-injector.ts b/.agents/hooks/core/skill-injector.ts index 4a2ab5a..66c8139 100644 --- a/.agents/hooks/core/skill-injector.ts +++ b/.agents/hooks/core/skill-injector.ts @@ -2,7 +2,7 @@ /** * oh-my-agent — Skill Injector Hook (UserPromptSubmit) * - * Works with: Claude Code, Codex CLI, Gemini CLI, Cursor, Qwen Code. + * Works with: Claude Code, Codex CLI, Cursor, Qwen Code. * * Discovers `.agents/skills//` directories (requires `SKILL.md` to exist), * looks up multilingual triggers from `triggers.json` (`skills` section), @@ -21,18 +21,15 @@ import { writeFileSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; -import { - agyConversationId, - agyProjectDir, - isAgyInput, - readAgyPrompt, -} from "./agy-input.ts"; -import { resolveGitRoot, toPosixPath } from "./fs-utils.ts"; +import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; +import { toPosixPath } from "./fs-utils.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { isRelayedAgentMessage, normalizePromptInput } from "./prompt-input.ts"; // triggers.json is imported statically: bundler inlines it into the oma binary; // standalone bun runs resolve the sibling file (pi / direct run). import embeddedTriggers from "./triggers.json" with { type: "json" }; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; const MAX_SKILLS = 3; const SESSION_TTL_MS = 60 * 60 * 1000; @@ -40,27 +37,10 @@ const DEFAULT_CJK_SCRIPTS = ["ko", "ja", "zh"]; // ── Vendor Detection ────────────────────────────────────────── -function inferVendorFromScriptPath(): Vendor | null { - const path = import.meta.filename; - if (path.includes(`${join(".gemini", "antigravity-cli", "hooks")}`)) - return "antigravity"; - if (path.includes(`${join(".cursor", "hooks")}`)) return "cursor"; - if (path.includes(`${join(".qwen", "hooks")}`)) return "qwen"; - if (path.includes(`${join(".claude", "hooks")}`)) return "claude"; - if (path.includes(`${join(".gemini", "hooks")}`)) return "gemini"; - if (path.includes(`${join(".codex", "hooks")}`)) return "codex"; - if (path.includes(`${join(".grok", "hooks")}`)) return "grok"; - if (path.includes(`${join(".kiro", "hooks")}`)) return "kiro"; - // pi auto-loads the bridge from `.pi/extensions/oma/`; the core scripts are - // copied alongside it and spawned as subprocesses from there. - if (path.includes(`${join(".pi", "extensions")}`)) return "pi"; - return null; -} - function detectVendor(input: Record): Vendor { const event = input.hook_event_name as string | undefined; const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(); + const byScriptPath = inferVendorFromScriptPath(import.meta.filename); if (byScriptPath) return byScriptPath; // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. @@ -79,7 +59,6 @@ function detectVendor(input: Record): Vendor { } if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; if (event === "beforeSubmitPrompt") return "cursor"; if (event === "UserPromptSubmit") { if ("session_id" in input && !("sessionId" in input)) return "codex"; @@ -88,45 +67,6 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - case "cursor": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "antigravity": - dir = - agyProjectDir(input) || - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.env.GEMINI_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - function getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -524,6 +464,11 @@ export async function run( if (!prompt.trim()) return null; + // Relayed inter-agent messages (teammate reports, idle notifications) are + // not user intent — their content routinely contains skill trigger words + // and produced false skill suggestions. Same guard as keyword-detector. + if (isRelayedAgentMessage(prompt)) return null; + // Claude-specific: slash-skill resolution must run BEFORE the slash early-exit // and persistent-workflow guard (same order as the original standalone path). if (vendor === "claude") { @@ -575,7 +520,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt`; recover it from the // transcript, and only act on the first invocation of a turn. diff --git a/.agents/hooks/core/state-boundary.ts b/.agents/hooks/core/state-boundary.ts index 569cfda..ddc1bd0 100644 --- a/.agents/hooks/core/state-boundary.ts +++ b/.agents/hooks/core/state-boundary.ts @@ -1,35 +1,22 @@ #!/usr/bin/env bun import { readFileSync } from "node:fs"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import { recallFacts } from "./agentmemory-client.ts"; -import { agyConversationId, agyProjectDir, isAgyInput } from "./agy-input.ts"; -import { resolveGitRoot } from "./fs-utils.ts"; +import { agyConversationId, isAgyInput } from "./agy-input.ts"; import { syncGrokContext } from "./grok-context.ts"; import { makePromptOutput } from "./hook-output.ts"; import { writeInjectLog } from "./inject-log.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; import { emitEvent, type OmaEvent, readEvents } from "./state-emit.ts"; import { getActiveSid, readIndex, setLastSession } from "./state-marker.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; import { type MemoryFact, renderStateSnapshot } from "./vendor-renderer.ts"; -function inferVendorFromScriptPath(): Vendor | null { - const path = import.meta.filename; - if (path.includes(`${join(".gemini", "antigravity-cli", "hooks")}`)) - return "antigravity"; - if (path.includes(`${join(".cursor", "hooks")}`)) return "cursor"; - if (path.includes(`${join(".qwen", "hooks")}`)) return "qwen"; - if (path.includes(`${join(".claude", "hooks")}`)) return "claude"; - if (path.includes(`${join(".gemini", "hooks")}`)) return "gemini"; - if (path.includes(`${join(".codex", "hooks")}`)) return "codex"; - if (path.includes(`${join(".grok", "hooks")}`)) return "grok"; - if (path.includes(`${join(".kiro", "hooks")}`)) return "kiro"; - return null; -} - function detectVendor(input: Record): Vendor { const event = input.hook_event_name as string | undefined; const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(); + const byScriptPath = inferVendorFromScriptPath(import.meta.filename); if (byScriptPath) return byScriptPath; // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. @@ -48,7 +35,6 @@ function detectVendor(input: Record): Vendor { } if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; if (event === "beforeSubmitPrompt") return "cursor"; if ( event === "UserPromptSubmit" && @@ -61,45 +47,6 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - case "cursor": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - case "antigravity": - dir = - agyProjectDir(input) || - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.env.GEMINI_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - function getVendorSid(input: Record): string { return ( (input.sessionId as string) || @@ -152,6 +99,7 @@ export async function onBoundary( vendor: Vendor, vendorSid: string, promptText?: string, + forced = false, ): Promise { const idx = readIndex(projectDir); const previous = idx.lastSession; @@ -159,7 +107,9 @@ export async function onBoundary( !previous || previous.vendor !== vendor || previous.vendorSid !== vendorSid; const statelessTurnFlush = vendor === "kiro" && vendorSid === "unknown"; - if (!boundary && !statelessTurnFlush) { + // `forced` = post-compaction SessionStart: the session id is unchanged (no + // boundary), but the snapshot was just compacted out of context — re-emit. + if (!boundary && !statelessTurnFlush && !forced) { setLastSession(projectDir, vendor, vendorSid); return null; } @@ -176,7 +126,9 @@ export async function onBoundary( vendorSid, payload: { reason: !boundary - ? "stateless-vendor-turn" + ? statelessTurnFlush + ? "stateless-vendor-turn" + : "post-compact-rehydration" : previous ? "vendor-session-transition" : "session-created", @@ -243,12 +195,15 @@ export async function run( const { vendor, cwd: projectDir, sid: vendorSid = "unknown" } = ctx; // input.kind === "prompt" is guaranteed by the guard above; the user prompt is - // the primary recall signal for boundary rehydration. + // the primary recall signal for boundary rehydration. A post-compaction + // SessionStart (source === "compact") forces re-emission: the session id is + // unchanged, but the snapshot was just compacted out of the context window. const rendered = await onBoundary( projectDir, vendor, vendorSid, input.prompt, + input.source === "compact", ); if (!rendered) return null; return { type: "context", additionalContext: rendered }; @@ -272,7 +227,7 @@ async function main() { // Delegate to run() — single logic source. const hookInput: HookInput = { kind: "prompt", - prompt: (input.prompt as string) ?? "", + prompt: normalizePromptInput(input.prompt), cwd: projectDir, }; const ctxVal: HandlerCtx = { vendor, cwd: projectDir, sid: vendorSid }; diff --git a/.agents/hooks/core/test-filter.ts b/.agents/hooks/core/test-filter.ts index 25ba1cd..3034ec1 100644 --- a/.agents/hooks/core/test-filter.ts +++ b/.agents/hooks/core/test-filter.ts @@ -1,11 +1,11 @@ // PreToolUse hook — Filter test output to show only failures -// Works with: Claude Code, Codex CLI, Gemini CLI, Qwen Code +// Works with: Claude Code, Codex CLI, Qwen Code import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { resolveGitRoot } from "./fs-utils.ts"; import { makePreToolOutput } from "./hook-output.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { getHookDir, getProjectDir } from "./vendor-detect.ts"; // --- Vendor detection (same logic as keyword-detector.ts) --- @@ -20,7 +20,6 @@ function detectVendor(input: Record): Vendor { if (process.env.GROK_WORKSPACE_ROOT) return "grok"; if (process.env.KIRO_PROJECT_DIR) return "kiro"; - if (event === "BeforeTool") return "gemini"; if (event === "preToolUse" || _hookEventName === "preToolUse") return "kiro"; if (event === "PreToolUse" && process.env.ANTIGRAVITY_PROJECT_DIR) return "antigravity"; @@ -31,81 +30,6 @@ function detectVendor(input: Record): Vendor { return "claude"; } -function getProjectDir(vendor: Vendor, input: Record): string { - let dir: string; - switch (vendor) { - case "codex": - dir = (input.cwd as string) || process.cwd(); - break; - case "gemini": - dir = process.env.GEMINI_PROJECT_DIR || process.cwd(); - break; - case "antigravity": - dir = - (input.cwd as string) || - process.env.ANTIGRAVITY_PROJECT_DIR || - process.env.AGY_PROJECT_DIR || - process.cwd(); - break; - case "qwen": - dir = process.env.QWEN_PROJECT_DIR || process.cwd(); - break; - case "grok": - dir = - process.env.GROK_WORKSPACE_ROOT || - (input.cwd as string) || - process.cwd(); - break; - case "kiro": - dir = - process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); - break; - default: - dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - break; - } - return resolveGitRoot(dir); -} - -/** - * Vendor → hooks directory (relative to the project root) where - * `filter-test-output.sh` is materialized by the installer. - * - * MUST mirror the `hookDir` field of `.agents/hooks/variants/.json`. - * This switch cannot import the variant JSONs (pi spawns this script as a - * standalone subprocess from a directory where variants are not copied), so - * the mapping is duplicated here and locked by a contract test - * (`cli/commands/hook/vendor-wiring.test.ts`). - */ -export function getHookDir(vendor: Vendor): string { - switch (vendor) { - case "claude": - return ".claude/hooks"; - case "codex": - return ".codex/hooks"; - case "commandcode": - return ".commandcode/hooks"; - case "cursor": - return ".cursor/hooks"; - case "gemini": - return ".gemini/hooks"; - case "antigravity": - // agy has no project hook dir — its `.agents/hooks.json` runs handlers - // straight from the SSOT core dir, where filter-test-output.sh lives. - return ".agents/hooks/core"; - case "qwen": - return ".qwen/hooks"; - case "grok": - return ".grok/hooks"; - case "kiro": - return ".kiro/hooks"; - case "pi": - // pi keeps the core scripts (and filter-test-output.sh) inside the - // bridge's directory extension, not a dedicated hooks dir. - return join(".pi", "extensions", "oma"); - } -} - // --- Test runner patterns --- const TEST_PATTERNS = [ @@ -179,8 +103,16 @@ export async function run( const { toolName, toolInput, cwd: projectDir } = input; const { vendor } = ctx; - // Gemini uses run_shell_command; Claude-family uses Bash. - if (toolName !== "Bash" && toolName !== "run_shell_command") return null; + // Claude-family uses Bash; some CLIs use run_shell_command; Cursor names its + // terminal tool "Shell" (matches cursor.json's preToolUse matcher); Kiro's + // canonical shell tool is execute_bash (the agent-JSON matcher name). + if ( + toolName !== "Bash" && + toolName !== "run_shell_command" && + toolName !== "Shell" && + toolName !== "execute_bash" + ) + return null; const command = toolInput.command as string | undefined; if (!command) return null; @@ -191,12 +123,18 @@ export async function run( const isExcluded = EXCLUDE_PATTERNS.some((p) => p.test(command)); if (isExcluded) return null; - const filterScript = join( - projectDir, + // Resolve the filter script: vendor hook dir first, then the opencode + // bridge dir (opencode has no core Vendor identity — its subprocess payload + // detects as claude, whose hook dir is absent in opencode-only installs), + // then the SSOT core dir as the last resort. + const filterScript = [ getHookDir(vendor), - "filter-test-output.sh", - ); - if (!existsSync(filterScript)) return null; + join(".opencode", "plugins", "oma"), + join(".agents", "hooks", "core"), + ] + .map((dir) => join(projectDir, dir, "filter-test-output.sh")) + .find((p) => existsSync(p)); + if (!filterScript) return null; const filteredCmd = `set -o pipefail; (${command}) 2>&1 | bash "${filterScript}"`; const updatedInput: Record = { diff --git a/.agents/hooks/core/triggers.json b/.agents/hooks/core/triggers.json index 29747e0..6e0874d 100644 --- a/.agents/hooks/core/triggers.json +++ b/.agents/hooks/core/triggers.json @@ -2067,7 +2067,8 @@ "react", "tailwind", "tsx", - "frontend" + "frontend", + "angular" ], "en": [ "make a react component", @@ -2086,7 +2087,8 @@ "넥스트 페이지", "tailwind로 스타일", "shadcn 붙여줘", - "프론트 구현" + "프론트 구현", + "앵귤러" ], "ja": [ "reactコンポーネント", @@ -3033,6 +3035,54 @@ "会议转录" ] } + }, + "oma-refactor": { + "keywords": { + "*": ["oma-refactor"], + "en": [ + "refactor this", + "refactoring plan", + "extract class", + "extract method", + "reduce complexity", + "measure complexity", + "code smell", + "technical debt", + "characterization test", + "hotspot analysis", + "split this class", + "clean up this code" + ], + "ko": [ + "리팩토링", + "리팩터링", + "복잡도 줄여", + "복잡도 측정", + "코드 스멜", + "기술 부채", + "클래스 분리", + "메서드 추출", + "코드 정리해줘" + ], + "ja": [ + "リファクタリング", + "複雑度を下げ", + "複雑度を測定", + "コードスメル", + "技術的負債", + "クラスを分割", + "メソッド抽出" + ], + "zh": [ + "重构", + "降低复杂度", + "测量复杂度", + "代码异味", + "技术债", + "拆分类", + "提取方法" + ] + } } }, "informationalPatterns": { @@ -3066,7 +3116,6 @@ "should you", "could we", "would you", - "what if", "what about", "why build", "why create", @@ -3077,7 +3126,10 @@ "뭐야", "뭐임", "무엇", - "어떻게", + "어떻게 동작", + "어떻게 작동", + "어떻게 되는지", + "어떻게 하는지", "설명해", "알려줘", "키워드", @@ -3093,13 +3145,12 @@ "왜 만들", "어떻게 만들", "어떨까", - "하면 좋을", + "하면 좋을지", "한다면", "할까요", "보강할", "에 대해", "에 대한", - "한번 봐", "깊게 봐", "코드를 한번", "그 워크플로우", @@ -3175,7 +3226,7 @@ "wykryto" ] }, - "excludedWorkflows": ["tools", "stack-set"], + "excludedWorkflows": ["tools", "stack-set", "schedule"], "cjkScripts": ["ko", "ja", "zh"], "extensionRouting": { "frontend-engineer": [ diff --git a/.agents/hooks/core/types.ts b/.agents/hooks/core/types.ts index 20ba28c..a58ffda 100644 --- a/.agents/hooks/core/types.ts +++ b/.agents/hooks/core/types.ts @@ -1,5 +1,5 @@ // Hook-runtime types shared across Claude Code, Codex CLI, Cursor, -// Gemini CLI, and Qwen Code. Functions live in `fs-utils.ts` and +// and the other host CLIs in VENDORS. Functions live in `fs-utils.ts` and // `hook-output.ts`; this file is types-only. The `Vendor` type is derived // from the `VENDORS` runtime constant in `constants.ts` so the two stay // in sync. @@ -28,7 +28,7 @@ export interface RawHookInput { hook_event_name?: string; cwd?: string; workspace_roots?: string[]; - // Gemini: AfterAgent fields + // Stop/AfterAgent response text fields (used for re-trigger suppression). prompt_response?: string; stop_hook_active?: boolean; // Claude/Qwen: Stop fields @@ -54,7 +54,18 @@ export interface ModeState { * Discriminated on `kind`; produced by adapters.ts normalizeInput(). */ export type HookInput = - | { kind: "prompt"; prompt: string; cwd: string } + | { + kind: "prompt"; + prompt: string; + cwd: string; + /** + * SessionStart trigger source (claude: startup|resume|clear|compact). + * `compact` lets session-once handlers (serena-primer, state-boundary) + * force re-injection: compaction keeps the session id, so their normal + * dedup would otherwise skip exactly the turn that lost the context. + */ + source?: string; + } | { kind: "pre_tool"; toolName: string; diff --git a/.agents/hooks/core/vendor-detect.ts b/.agents/hooks/core/vendor-detect.ts new file mode 100644 index 0000000..ff945b2 --- /dev/null +++ b/.agents/hooks/core/vendor-detect.ts @@ -0,0 +1,112 @@ +// Shared vendor-detection helpers for the core hook handlers. +// +// Previously each handler (keyword-detector, state-boundary, skill-injector, +// serena-primer, persistent-mode, test-filter) carried its own copy of these +// functions, which drifted (different vendor subsets). They are event- +// independent — vendor → script path, project dir, hook dir — so they live here +// once and every handler imports them. `detectVendor` stays per-handler because +// the event-name → vendor mapping is hook-kind-specific (prompt/tool/stop). + +import { join } from "node:path"; +import { agyProjectDir } from "./agy-input.ts"; +import { resolveGitRoot } from "./fs-utils.ts"; +import type { Vendor } from "./types.ts"; + +/** + * Infer the vendor from the installed script path (`import.meta.filename` of the + * running handler). Returns null when the path matches no known vendor hook dir. + * agy lives under `.gemini/antigravity-cli/hooks/` and must be checked before the + * bare `.gemini/hooks/` case (which no longer exists post Gemini-CLI removal). + */ +export function inferVendorFromScriptPath(scriptPath: string): Vendor | null { + if (scriptPath.includes(`${join(".gemini", "antigravity-cli", "hooks")}`)) + return "antigravity"; + if (scriptPath.includes(`${join(".cursor", "hooks")}`)) return "cursor"; + if (scriptPath.includes(`${join(".qwen", "hooks")}`)) return "qwen"; + if (scriptPath.includes(`${join(".claude", "hooks")}`)) return "claude"; + if (scriptPath.includes(`${join(".codex", "hooks")}`)) return "codex"; + if (scriptPath.includes(`${join(".grok", "hooks")}`)) return "grok"; + if (scriptPath.includes(`${join(".kiro", "hooks")}`)) return "kiro"; + if (scriptPath.includes(`${join(".kimi-code", "hooks")}`)) return "kimi"; + // pi auto-loads the bridge from `.pi/extensions/oma/`; core scripts are copied + // alongside it and spawned as subprocesses from there. + if (scriptPath.includes(`${join(".pi", "extensions")}`)) return "pi"; + return null; +} + +/** Resolve the git-root project directory for a vendor + raw hook input. */ +export function getProjectDir( + vendor: Vendor, + input: Record, +): string { + let dir: string; + switch (vendor) { + case "codex": + case "cursor": + dir = (input.cwd as string) || process.cwd(); + break; + case "antigravity": + dir = + agyProjectDir(input) || + (input.cwd as string) || + process.env.ANTIGRAVITY_PROJECT_DIR || + process.env.AGY_PROJECT_DIR || + process.env.GEMINI_PROJECT_DIR || + process.cwd(); + break; + case "qwen": + dir = process.env.QWEN_PROJECT_DIR || process.cwd(); + break; + case "grok": + dir = + process.env.GROK_WORKSPACE_ROOT || + (input.cwd as string) || + process.cwd(); + break; + case "kiro": + dir = + process.env.KIRO_PROJECT_DIR || (input.cwd as string) || process.cwd(); + break; + default: + dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + break; + } + return resolveGitRoot(dir); +} + +/** + * Vendor → hooks directory (relative to the project root) where vendor scripts + * like `filter-test-output.sh` are materialized by the installer. MUST mirror + * the `hookDir` field of `.agents/hooks/variants/.json`; locked by the + * contract test `cli/commands/hook/vendor-wiring.test.ts`. + */ +export function getHookDir(vendor: Vendor): string { + switch (vendor) { + case "claude": + return ".claude/hooks"; + case "codex": + return ".codex/hooks"; + case "commandcode": + return ".commandcode/hooks"; + case "cursor": + return ".cursor/hooks"; + case "antigravity": + // agy has no project hook dir — its `.agents/hooks.json` runs handlers + // straight from the SSOT core dir, where filter-test-output.sh lives. + return ".agents/hooks/core"; + case "qwen": + return ".qwen/hooks"; + case "grok": + return ".grok/hooks"; + case "kiro": + return ".kiro/hooks"; + case "kimi": + // Kimi Code CLI is global-only (homeOnly variant): runtime hooks live in + // ~/.kimi-code/hooks, so there is no project hook dir. Mirror antigravity + // and point at the SSOT core dir; otherwise the rewrite no-ops gracefully. + return ".agents/hooks/core"; + case "pi": + // pi keeps the core scripts inside the bridge's directory extension. + return join(".pi", "extensions", "oma"); + } +} diff --git a/.agents/hooks/core/vendor-renderer.ts b/.agents/hooks/core/vendor-renderer.ts index 29a290b..85880fd 100644 --- a/.agents/hooks/core/vendor-renderer.ts +++ b/.agents/hooks/core/vendor-renderer.ts @@ -50,8 +50,8 @@ export function renderStateSnapshot(input: StateSnapshotRenderInput): string { case "codex": case "commandcode": case "cursor": - case "gemini": case "grok": + case "kimi": case "kiro": case "pi": case "qwen": diff --git a/.agents/hooks/variants/antigravity.json b/.agents/hooks/variants/antigravity.json index 0c9f5d6..f233148 100644 --- a/.agents/hooks/variants/antigravity.json +++ b/.agents/hooks/variants/antigravity.json @@ -25,11 +25,6 @@ "timeout": 3 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "run_command", - "timeout": 5 - }, "Stop": { "hook": "persistent-mode.ts", "timeout": 5 diff --git a/.agents/hooks/variants/claude.json b/.agents/hooks/variants/claude.json index 901d5a9..e319b13 100644 --- a/.agents/hooks/variants/claude.json +++ b/.agents/hooks/variants/claude.json @@ -6,22 +6,32 @@ "projectDirEnv": "CLAUDE_PROJECT_DIR", "runtime": "bun", "events": { + "SessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } + ], "UserPromptSubmit": [ { "hook": "keyword-detector.ts", - "timeout": 5 + "timeout": 2 }, { "hook": "state-boundary.ts", - "timeout": 5 + "timeout": 4 }, { "hook": "skill-injector.ts", - "timeout": 3 + "timeout": 2 }, { "hook": "serena-primer.ts", - "timeout": 3 + "timeout": 2 } ], "PreToolUse": { diff --git a/.agents/hooks/variants/codex.json b/.agents/hooks/variants/codex.json index 0609cab..4000cb5 100644 --- a/.agents/hooks/variants/codex.json +++ b/.agents/hooks/variants/codex.json @@ -33,12 +33,5 @@ "hook": "persistent-mode.ts", "timeout": 5 } - }, - "featureFlags": { - "file": ".codex/config.toml", - "section": "features", - "flags": { - "hooks": true - } } } diff --git a/.agents/hooks/variants/commandcode.json b/.agents/hooks/variants/commandcode.json index 150f1ef..67b1819 100644 --- a/.agents/hooks/variants/commandcode.json +++ b/.agents/hooks/variants/commandcode.json @@ -6,6 +6,16 @@ "projectDirEnv": null, "runtime": "bun", "events": { + "SessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } + ], "Stop": { "hook": "persistent-mode.ts", "timeout": 5 diff --git a/.agents/hooks/variants/cursor.json b/.agents/hooks/variants/cursor.json index 8feb024..390ff7b 100644 --- a/.agents/hooks/variants/cursor.json +++ b/.agents/hooks/variants/cursor.json @@ -24,6 +24,26 @@ "hook": "serena-primer.ts", "timeout": 3 } + ], + "preToolUse": { + "hook": "test-filter.ts", + "matcher": "Shell", + "timeout": 5 + }, + "stop": { + "hook": "persistent-mode.ts", + "timeout": 5, + "loopLimit": null + }, + "sessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } ] }, "extra": { diff --git a/.agents/hooks/variants/grok.json b/.agents/hooks/variants/grok.json index a0ee2bf..8b19400 100644 --- a/.agents/hooks/variants/grok.json +++ b/.agents/hooks/variants/grok.json @@ -26,7 +26,7 @@ ], "PreToolUse": { "hook": "test-filter.ts", - "matcher": "run_terminal_cmd", + "matcher": "Bash", "timeout": 5 }, "Stop": { diff --git a/.agents/hooks/variants/hook-variant.schema.json b/.agents/hooks/variants/hook-variant.schema.json index ff91d06..4ac2056 100644 --- a/.agents/hooks/variants/hook-variant.schema.json +++ b/.agents/hooks/variants/hook-variant.schema.json @@ -20,8 +20,8 @@ "codex", "commandcode", "cursor", - "gemini", "grok", + "kimi", "kiro", "qwen" ] @@ -86,6 +86,11 @@ "flatHookEntries": { "type": "boolean", "description": "When true, settings hook entries are written as flat {command, timeout[, matcher]} objects under each event key (Cursor hooks.json format) instead of nested {matcher, hooks:[...]} groups (Claude Code format)." + }, + "skipSettingsMerge": { + "type": "boolean", + "description": "When true, the generic installer copies hook scripts and writes the oma-hook.sh wrapper but does NOT merge hook entries into settingsFile. Used by Kiro, whose CLI reads hooks from a dedicated agent config (.kiro/agents/oma-hooks.json via applyKiroOmaHooksAgent) — the generic .kiro/settings/cli.json merge would be dead config Kiro never reads. `extra` (statusLine/permissions) is still applied when present.", + "default": false } }, "$defs": { @@ -109,6 +114,10 @@ "minimum": 1, "maximum": 30, "default": 5 + }, + "loopLimit": { + "type": ["integer", "null"], + "description": "Cursor `stop` hook only: caps how many times the hook may auto-resubmit a followup_message (Cursor's loop_limit). null = uncapped; omit for Cursor's default (5). Ignored by other vendors." } } }, diff --git a/.agents/hooks/variants/kimi.json b/.agents/hooks/variants/kimi.json new file mode 100644 index 0000000..ed163ea --- /dev/null +++ b/.agents/hooks/variants/kimi.json @@ -0,0 +1,38 @@ +{ + "$schema": "./hook-variant.schema.json", + "vendor": "kimi", + "hookDir": ".agents/hooks/core", + "settingsFile": ".kimi-code/config.toml", + "projectDirEnv": null, + "runtime": "bun", + "homeOnly": true, + "events": { + "UserPromptSubmit": [ + { + "hook": "keyword-detector.ts", + "timeout": 5 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + }, + { + "hook": "skill-injector.ts", + "timeout": 3 + }, + { + "hook": "serena-primer.ts", + "timeout": 3 + } + ], + "PreToolUse": { + "hook": "test-filter.ts", + "matcher": "Bash", + "timeout": 5 + }, + "Stop": { + "hook": "persistent-mode.ts", + "timeout": 5 + } + } +} diff --git a/.agents/hooks/variants/kiro.json b/.agents/hooks/variants/kiro.json index 8a01e24..4b659d1 100644 --- a/.agents/hooks/variants/kiro.json +++ b/.agents/hooks/variants/kiro.json @@ -5,6 +5,7 @@ "settingsFile": ".kiro/settings/cli.json", "projectDirEnv": null, "runtime": "bun", + "skipSettingsMerge": true, "events": { "userPromptSubmit": [ { diff --git a/.agents/hooks/variants/opencode/oma.ts b/.agents/hooks/variants/opencode/oma.ts new file mode 100644 index 0000000..51060a5 --- /dev/null +++ b/.agents/hooks/variants/opencode/oma.ts @@ -0,0 +1,410 @@ +/** + * oh-my-agent — opencode (Sst opencode) plugin bridge. + * + * SSOT source. At install time `installOpencodePlugin` copies this file to + * `.opencode/plugins/oma/oma.ts` alongside the core hook scripts, and + * `registerOpencodePlugin` adds it to `.opencode/opencode.jsonc` (the nested + * subdir is invisible to opencode's flat auto-discovery). + * + * Why a bridge instead of a per-vendor variants JSON entry: opencode does NOT + * register settings-file hooks like the other vendors. It loads in-process + * TypeScript plugins whose handlers are invoked with an `(input, output)` pair + * and are expected to MUTATE `output` (return values are ignored). So rather + * than the generic `installHooksFromVariant` path (events → settings file → + * `bun