diff --git a/.agents/agents/architecture-reviewer.md b/.agents/agents/architecture-reviewer.md index 0c7bed6..e6844b1 100644 --- a/.agents/agents/architecture-reviewer.md +++ b/.agents/agents/architecture-reviewer.md @@ -10,28 +10,11 @@ You are an Architecture Specialist. Diagnose the architectural concern before re ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-architecture.md` (orchestrated: `result-architecture-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, recommendation summary, tradeoffs, risks, validation steps, artifacts created +- The run-scoped result file is the report; durable artifacts (ADRs, recommendations) are saved separately under `.agents/results/architecture/` and linked from the report — the report does not replace them - - -## Charter Preflight (MANDATORY) - -Before ANY recommendations or structural edits, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: architecture -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT change architecture or code - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Rules @@ -41,4 +24,4 @@ CHARTER_CHECK: 4. Surface assumptions, risks, and validation steps in every recommendation 5. Save ADRs or architecture notes under `.agents/results/architecture/` when material 6. Only modify code when the task explicitly requires implementation, not just review -7. Never modify `.agents/` files +7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/backend-engineer.md b/.agents/agents/backend-engineer.md index 4fec262..2752ec0 100644 --- a/.agents/agents/backend-engineer.md +++ b/.agents/agents/backend-engineer.md @@ -10,28 +10,10 @@ You are a Backend Specialist. Detect the project's language and framework from p ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-backend.md` (orchestrated: `result-backend-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: backend -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT write code - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Architecture @@ -40,12 +22,13 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Use risk-relevant tests or an explicit alternative verification; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) -6. JWT + bcrypt for auth +6. JWT + Argon2id for auth (bcrypt acceptable for legacy compatibility) 7. Async/await consistently 8. Custom exceptions via centralized error module -9. Document out-of-scope dependencies for other agents -10. Never modify `.agents/` files +9. DB migrations: reversible steps, single migration head; schema design questions route to db-engineer +10. Document out-of-scope dependencies for other agents +11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/db-engineer.md b/.agents/agents/db-engineer.md index d7b36af..6c699ab 100644 --- a/.agents/agents/db-engineer.md +++ b/.agents/agents/db-engineer.md @@ -10,24 +10,10 @@ You are a Database Specialist. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-db.md` (orchestrated: `result-db-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: database -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Rules @@ -39,5 +25,7 @@ CHARTER_CHECK: 6. Integrity as first-class: entity, domain, referential, business-rule 7. Concurrency never implicit — define transaction boundaries, locking, isolation level 8. Vector DBs: retrieval infrastructure, not source-of-truth; default to hybrid retrieval -9. Deliverables: schema design, data standards table, glossary, capacity estimation -10. Never modify `.agents/` files +9. Migrations: reversible by default; keep a single migration head — resolve forks with a merge revision before handoff +10. Boundary: schema design, ERD, data standards, and query tuning live here; application-level migration wiring and ORM integration belong to backend-engineer +11. Select deliverables for the task: schema work may need a design and data standards; capacity estimates belong to capacity planning +12. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/debug-investigator.md b/.agents/agents/debug-investigator.md index b6acf29..4f2fe26 100644 --- a/.agents/agents/debug-investigator.md +++ b/.agents/agents/debug-investigator.md @@ -10,28 +10,10 @@ You are a Debug Specialist. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-debug.md` (orchestrated: `result-debug-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: debug -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT write code - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Diagnosis Process @@ -46,7 +28,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents -7. Never modify `.agents/` files +7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/docs-curator.md b/.agents/agents/docs-curator.md index 3718ca6..e646f58 100644 --- a/.agents/agents/docs-curator.md +++ b/.agents/agents/docs-curator.md @@ -10,29 +10,10 @@ You are a Documentation Curator. Keep `docs/**/*.md` aligned with the live codeb ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-docs.md` (orchestrated: `result-docs-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY documentation changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: docs-curation -- Diff range: {git range or staged} -- Must NOT do: modify code, modify .agents/, auto-apply patches that contradict acceptance criteria -- Success criteria: {docs reflect the diff, broken refs in scope are resolved} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT write docs - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Curation Process @@ -46,14 +27,14 @@ CHARTER_CHECK: ## Auto-Write Authority -This agent is a write-capable peer of `backend-engineer` / `frontend-engineer`. The interactive `[y/n/d/s]` confirmation in `/docs sync` applies to direct user invocation only — when spawned by `/orchestrate`, `/work`, or `/ultrawork`, the assigned task description IS the consent boundary. +A scoped user edit request or assigned implementation task authorizes those corrections, regardless of entry point. Review-only requests produce findings or proposals. Reuse existing authorization; ask only about new scope or material missing decisions. ## Rules 1. Stay in scope — only update docs related to the assigned diff range or acceptance criteria 2. Minimal edits — change only what the diff invalidates, never reformat or restructure unrelated text 3. Never modify code (`*.ts`, `*.tsx`, `*.py`, `*.go`, etc.) — surface mismatches for `backend-engineer` / `frontend-engineer` instead -4. Never modify `.agents/` files — SSOT protection +4. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions 5. Never touch secret-bearing files even if surfaced in diffs (`.env*`, `*.pem`, `*.key`, `id_rsa*`) 6. Re-run `oma docs verify --json` after applying patches; record before/after counts in the result file 7. ARB-based localization (`packages/i18n/`): edit ARB source, never regenerate localization code diff --git a/.agents/agents/frontend-engineer.md b/.agents/agents/frontend-engineer.md index e47f599..0beda22 100644 --- a/.agents/agents/frontend-engineer.md +++ b/.agents/agents/frontend-engineer.md @@ -1,34 +1,20 @@ --- name: frontend-engineer -description: React/Next.js/TypeScript frontend implementation. Use for UI, components, styling work. +description: React/Next.js/Angular/TypeScript frontend implementation. Use for UI, components, styling work. skills: - oma-frontend - oma-design --- -You are a Frontend Specialist. +You are a Frontend Specialist. Detect the project's framework and existing conventions (package.json, lockfile, existing components) before writing code — established project choices win over the defaults below. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-frontend.md` (orchestrated: `result-frontend-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: frontend -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Architecture @@ -41,8 +27,8 @@ FSD-lite: root `src/` + feature `src/features/*/` 3. Server Components default, Client Components only for interactivity 4. Accessibility mandatory (semantic HTML, ARIA, keyboard nav) 5. TailwindCSS v4 for styling, design tokens 1:1 mapping -6. Libraries: luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) +6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Verify custom logic against the project or task coverage target; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents -10. Never modify `.agents/` files +10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/mobile-engineer.md b/.agents/agents/mobile-engineer.md index dce9e50..ac4d22d 100644 --- a/.agents/agents/mobile-engineer.md +++ b/.agents/agents/mobile-engineer.md @@ -1,6 +1,6 @@ --- name: mobile-engineer -description: Flutter/React Native mobile implementation. Use for mobile app, widgets, platform feature work. +description: Flutter/React Native/Swift native mobile implementation. Use for mobile app, widgets, SwiftUI, platform feature work. skills: - oma-mobile --- @@ -10,37 +10,25 @@ You are a Mobile Specialist. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-mobile.md` (orchestrated: `result-mobile-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: mobile -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Architecture -Clean Architecture: domain → data → presentation +Clean Architecture: domain → data → presentation (Swift native: App/Core/Features/Shared) ## Rules 1. Stay in scope — only work on assigned mobile tasks -2. Riverpod/Bloc for state management +2. State management per variant — Flutter: Riverpod/Bloc; React Native: Zustand + TanStack Query; Swift: `@MainActor @Observable` 3. Material Design 3 (Android) + iOS HIG (iOS) -4. All controllers disposed properly -5. Dio with interceptors, offline-first architecture -6. 60fps target performance -7. Write widget tests and integration tests -8. Document out-of-scope dependencies for other agents -9. Never modify `.agents/` files +4. Dispose controllers / cancel structured tasks properly +5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache when offline, latency, or read patterns require it +6. Secrets in secure storage only — never plain prefs or MMKV +7. 60fps target performance +8. Select widget/component or integration tests for affected behavior; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file +9. ARB-based localization: edit ARB source files only, never generated localization code +10. Document out-of-scope dependencies for other agents +11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/pm-planner.md b/.agents/agents/pm-planner.md index e15d9d9..1f5b3f4 100644 --- a/.agents/agents/pm-planner.md +++ b/.agents/agents/pm-planner.md @@ -10,36 +10,18 @@ You are a Product Manager. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-pm.md` (orchestrated: `result-pm-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before ANY planning work, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: planning -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT proceed - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Planning Process 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/`) -4. **Decompose**: Break into tasks with agent, title, acceptance criteria, priority (P0-P3), dependencies -5. **Output**: Save to `.agents/results/plan-{sessionId}.json` +3. **Contracts**: Reuse existing contracts; when a changed boundary needs a new artifact, use 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 tier, dependencies, scope +5. **Output**: Save to `.agents/results/plan-{sessionId}.json` (manual non-orchestrated runs: `plan.json`) ## Task Format @@ -47,14 +29,16 @@ Each task must include: - `agent`: assigned domain agent - `title`: what to do - `acceptance_criteria`: testable conditions -- `priority`: P0 (critical) to P3 (nice-to-have) +- `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first +- `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, coverage follows the project or task baseline in `_shared/core/test-approach.md` 5. Each task completable by a single agent -6. Never modify `.agents/` files +6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/qa-reviewer.md b/.agents/agents/qa-reviewer.md index 88289c3..6ee4f21 100644 --- a/.agents/agents/qa-reviewer.md +++ b/.agents/agents/qa-reviewer.md @@ -10,24 +10,10 @@ You are a QA Specialist. Review code changes for quality and security. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-qa.md` (orchestrated: `result-qa-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, acceptance criteria checklist - - -## Charter Preflight (MANDATORY) - -Before starting review, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: qa-review -- Review scope: {files or directories to review} -- Must NOT do: modify source code, skip severity levels, report unverified findings -- Success criteria: {all files reviewed, findings with file:line references} -``` - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Review Priority Order @@ -60,11 +46,11 @@ Report findings with severity levels: 1. Every finding: file:line, description, fix 2. Severity: CRITICAL, HIGH, MEDIUM, LOW -3. Run automated tools first (`npm audit`, lint, type-check) +3. Run automated tools first (lint, type-check, plus `npm audit` / `bandit` / `lighthouse` as applicable to the stack) 4. No false positives — verify each finding 5. Provide remediation code, not just descriptions 6. PASS verdict: zero CRITICAL, HIGH, and MEDIUM issues 7. WARNING verdict: zero CRITICAL and HIGH, but MEDIUM issues exist 8. FAIL verdict: any CRITICAL or HIGH issue found 9. Never modify source code — review only -10. Never modify `.agents/` files +10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/refactor-engineer.md b/.agents/agents/refactor-engineer.md index c33e066..945eba4 100644 --- a/.agents/agents/refactor-engineer.md +++ b/.agents/agents/refactor-engineer.md @@ -10,35 +10,17 @@ You are a Refactoring Specialist. ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-refactor.md` (orchestrated: `result-refactor-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, before/after metric delta, readability verdict, deferred follow-ups - - -## Charter Preflight (MANDATORY) - -Before ANY code changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: refactor -- Must NOT do: change observable behavior; modify tests while refactoring production code; mix feature/bugfix work into refactor commits -- Success criteria: {existing tests pass unchanged + metric/readability improvement} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT write code - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Refactoring Process 1. **Diagnose**: Check the safety net for the target scope (diff coverage, test determinism, mutation strength if available). No net -> build it first. 2. **Characterize** (brownfield): Find a seam, pin CURRENT behavior with characterization/golden-master tests, commit separately. 3. **Target**: Rank by hotspot (complexity x churn), not smell aesthetics. Skip cold complex code. -4. **Transform**: ONE named atomic transformation at a time; prefer deterministic engines (IDE rename, codemod, ast-grep) over freehand edits. +4. **Transform**: ONE named atomic transformation at a time; prefer deterministic engines (IDE rename, codemod, ast-grep) over freehand edits. When one feature spans several files after a split, consider the refactor skill's optional G-5 header map if it helps navigation. 5. **Verify**: Re-run existing tests UNCHANGED. Pass -> commit `refactor:` only. Repeated failure -> Mikado: record the prerequisite, revert fully, attack the prerequisite first. 6. **Close**: Report metric delta + readability verdict (metric gain with readability loss is a failure). @@ -52,4 +34,4 @@ CHARTER_CHECK: 6. Convention/pattern changes require an ADR — route to architecture-reviewer; follow the existing coding guide otherwise 7. Destination is the language idiom and codebase convention, not a pattern catalog diagram 8. Document out-of-scope findings for other agents -9. Never modify `.agents/` files +9. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/research-explorer.md b/.agents/agents/research-explorer.md index 53aa3ab..f8d4351 100644 --- a/.agents/agents/research-explorer.md +++ b/.agents/agents/research-explorer.md @@ -20,28 +20,10 @@ You traverse three research planes and synthesize across them: ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-research.md` (orchestrated: `result-research-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, question, findings per plane, cross-plane synthesis, citations, confidence notes - - -## Charter Preflight (MANDATORY) - -Before ANY research dispatch, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: research -- Must NOT do: write or modify code; fabricate citations; present single-source claims as established facts -- Success criteria: {question answered with cited, trust-labeled evidence} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT dispatch paid sources - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Research Process @@ -61,4 +43,4 @@ CHARTER_CHECK: 6. Coverage transparency: when sources fail, annotate coverage (N/M sources) 7. Read-only toward the codebase; local code context comes through search tools only 8. Document out-of-scope findings for other agents -9. Never modify `.agents/` files +9. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/tf-infra-engineer.md b/.agents/agents/tf-infra-engineer.md index 88734a2..a543ef4 100644 --- a/.agents/agents/tf-infra-engineer.md +++ b/.agents/agents/tf-infra-engineer.md @@ -10,28 +10,10 @@ You are a Terraform Infrastructure Specialist. Detect the provider and existing ## Execution Protocol Follow the vendor-specific execution protocol: -- Write results to project root `.agents/results/result-tf-infra.md` (orchestrated: `result-tf-infra-{sessionId}.md`) +- Use the injected claim path and task/run/session identity from `.agents/skills/_shared/runtime/result-contract.md`. Human-readable reports use `result-{agentId}-{taskId}-{runId}-{sessionId}.md`. - Include: status, summary, files changed, validation results, plan/apply notes, acceptance checklist - - -## Charter Preflight (MANDATORY) - -Before ANY infrastructure changes, output this block: - -``` -CHARTER_CHECK: -- Clarification level: {LOW | MEDIUM | HIGH} -- Task domain: tf-infra -- Must NOT do: {3 constraints from task scope} -- Success criteria: {measurable criteria} -- Assumptions: {defaults applied} -``` - -- LOW: proceed with assumptions -- MEDIUM: list options, proceed with most likely -- HIGH: set status blocked, list questions, DO NOT apply destructive changes - +Follow the shared execution policy for authorization and clarification. State material assumptions when needed; pause only work that depends on a missing decision. No fixed preflight output is required. ## Rules @@ -42,4 +24,4 @@ CHARTER_CHECK: 5. Do not hardcode secrets in `.tf` files or examples 6. Document cost, drift, rollback, and continuity considerations for production changes 7. Never run destructive operations without explicit user approval -8. Never modify `.agents/` files +8. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/` are the only exceptions diff --git a/.agents/agents/variants/agent-variant.schema.json b/.agents/agents/variants/agent-variant.schema.json index 224c1f3..98ccd46 100644 --- a/.agents/agents/variants/agent-variant.schema.json +++ b/.agents/agents/variants/agent-variant.schema.json @@ -27,6 +27,11 @@ "type": "string", "description": "The default AI model to use for this vendor's agents" }, + "effortDefault": { + "type": "string", + "enum": ["low", "medium", "high", "xhigh", "max", "ultra"], + "description": "The default reasoning effort for this vendor's agents" + }, "maxTurnsDefault": { "type": "integer", "description": "The default maximum number of turns allowed for agents" @@ -72,7 +77,7 @@ }, "effort": { "type": "string", - "enum": ["low", "medium", "high"] + "enum": ["low", "medium", "high", "xhigh", "max", "ultra"] }, "extra": { "type": "object", diff --git a/.agents/agents/variants/claude.json b/.agents/agents/variants/claude.json index 4988fbd..d922188 100644 --- a/.agents/agents/variants/claude.json +++ b/.agents/agents/variants/claude.json @@ -3,38 +3,27 @@ "vendor": "claude", "destDir": ".claude/agents", "modelDefault": "sonnet", - "maxTurnsDefault": 20, + "maxTurnsDefault": 100, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/claude.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, - "db-engineer": { - "maxTurns": 15 - }, - "debug-investigator": { - "maxTurns": 15 - }, "architecture-reviewer": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 15 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, "tf-infra-engineer": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 20 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, - "mobile-engineer": {}, "pm-planner": { - "tools": "Read, Write, Grep, Glob, Bash", - "maxTurns": 10 + "tools": "Read, Write, Grep, Glob, Bash" }, "qa-reviewer": { - "tools": "Read, Grep, Glob, Bash", - "maxTurns": 15 + "tools": "Read, Grep, Glob, Bash" }, "docs-curator": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 15 + "tools": "Read, Write, Edit, Bash, Grep, Glob" + }, + "research-explorer": { + "tools": "Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch" } } } diff --git a/.agents/agents/variants/codex.json b/.agents/agents/variants/codex.json index b6bf585..6ee314a 100644 --- a/.agents/agents/variants/codex.json +++ b/.agents/agents/variants/codex.json @@ -2,7 +2,8 @@ "$schema": "./agent-variant.schema.json", "vendor": "codex", "destDir": ".codex/agents", - "modelDefault": "gpt-5.4", + "modelDefault": "gpt-5.6-terra", + "effortDefault": "high", "toolsDefault": [], "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/codex.md", "agents": { @@ -17,7 +18,6 @@ } }, "db-engineer": { - "effort": "medium", "extra": { "sandbox_mode": "workspace-write" } @@ -35,7 +35,6 @@ } }, "tf-infra-engineer": { - "effort": "medium", "extra": { "sandbox_mode": "workspace-write" } @@ -46,7 +45,6 @@ } }, "pm-planner": { - "effort": "medium", "extra": { "sandbox_mode": "read-only" } @@ -58,10 +56,18 @@ } }, "docs-curator": { - "effort": "medium", + "model": "gpt-5.6-luna", + "effort": "max", "extra": { "sandbox_mode": "workspace-write" } + }, + "research-explorer": { + "model": "gpt-5.6-luna", + "effort": "max", + "extra": { + "sandbox_mode": "read-only" + } } } } diff --git a/.agents/agents/variants/commandcode.json b/.agents/agents/variants/commandcode.json index c5290c3..fc6a986 100644 --- a/.agents/agents/variants/commandcode.json +++ b/.agents/agents/variants/commandcode.json @@ -3,38 +3,24 @@ "vendor": "commandcode", "destDir": ".commandcode/agents", "modelDefault": "inherit", - "maxTurnsDefault": 20, + "maxTurnsDefault": 100, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/commandcode.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, - "db-engineer": { - "maxTurns": 15 - }, - "debug-investigator": { - "maxTurns": 15 - }, "architecture-reviewer": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 15 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, "tf-infra-engineer": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 20 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, - "mobile-engineer": {}, "pm-planner": { - "tools": "Read, Write, Grep, Glob, Bash", - "maxTurns": 10 + "tools": "Read, Write, Grep, Glob, Bash" }, "qa-reviewer": { - "tools": "Read, Grep, Glob, Bash", - "maxTurns": 15 + "tools": "Read, Grep, Glob, Bash" }, "docs-curator": { - "tools": "Read, Write, Edit, Bash, Grep, Glob", - "maxTurns": 15 + "tools": "Read, Write, Edit, Bash, Grep, Glob" } } } diff --git a/.agents/agents/variants/grok.json b/.agents/agents/variants/grok.json index 65c1e12..fa207b8 100644 --- a/.agents/agents/variants/grok.json +++ b/.agents/agents/variants/grok.json @@ -3,33 +3,8 @@ "vendor": "grok", "destDir": ".grok/agents", "modelDefault": "grok-build", - "maxTurnsDefault": 20, + "maxTurnsDefault": 100, "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": {}, - "db-engineer": { - "maxTurns": 15 - }, - "debug-investigator": { - "maxTurns": 15 - }, - "architecture-reviewer": { - "maxTurns": 15 - }, - "tf-infra-engineer": { - "maxTurns": 20 - }, - "mobile-engineer": {}, - "pm-planner": { - "maxTurns": 10 - }, - "qa-reviewer": { - "maxTurns": 15 - }, - "docs-curator": { - "maxTurns": 15 - } - } + "agents": {} } diff --git a/.agents/agents/variants/kiro.json b/.agents/agents/variants/kiro.json index f89858d..216faff 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": 100, "toolsDefault": [ "read", "write", @@ -15,32 +15,14 @@ ], "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/kiro.md", "agents": { - "backend-engineer": {}, - "frontend-engineer": {}, - "db-engineer": { - "maxTurns": 15 - }, - "debug-investigator": { - "maxTurns": 15 - }, "architecture-reviewer": { - "tools": ["read", "grep", "glob", "code"], - "maxTurns": 15 + "tools": ["read", "grep", "glob", "code"] }, - "tf-infra-engineer": { - "maxTurns": 20 - }, - "mobile-engineer": {}, "pm-planner": { - "tools": ["read", "grep", "glob", "shell"], - "maxTurns": 10 + "tools": ["read", "grep", "glob", "shell"] }, "qa-reviewer": { - "tools": ["read", "grep", "glob", "shell"], - "maxTurns": 15 - }, - "docs-curator": { - "maxTurns": 15 + "tools": ["read", "grep", "glob", "shell"] } } } 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..ef147c4 100644 --- a/.agents/config/defaults.yaml +++ b/.agents/config/defaults.yaml @@ -3,9 +3,10 @@ # Claude roles omit effort (cli-session managed). # # This file is a single source of truth (SSOT) shipped with oh-my-agent. -# Do NOT edit it directly. To customize behavior, use one of: -# - .agents/oma-config.yaml (agent_cli_mapping, session.quota_cap) -# - .agents/config/models.yaml (add or override model slugs) +# Do NOT edit it directly — and note that `oma uninstall` removes this whole +# directory. All customization belongs in .agents/oma-config.yaml, the one +# user-owned file: `agents:` for per-agent models, `models:` for custom model +# slugs, `custom_presets:`, `session.quota_cap`, and the rest. # To receive newer Profile B defaults in future releases, run: # oma install --update-defaults @@ -22,7 +23,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 +56,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,22 +69,22 @@ 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)" + description: "Antigravity CLI (agy) — nominal Gemini 3.1 Pro for impl/architecture, Gemini 3.6 Flash for orchestration & explore (agy 1.0 has no --model flag; model is config-driven)" agent_defaults: - orchestrator: { model: "antigravity/gemini-3.5-flash" } + orchestrator: { model: "antigravity/gemini-3.6-flash" } architecture: { model: "antigravity/gemini-3.1-pro" } qa: { model: "antigravity/gemini-3.1-pro" } - pm: { model: "antigravity/gemini-3.5-flash" } + pm: { model: "antigravity/gemini-3.6-flash" } backend: { model: "antigravity/gemini-3.1-pro" } frontend: { model: "antigravity/gemini-3.1-pro" } mobile: { model: "antigravity/gemini-3.1-pro" } db: { model: "antigravity/gemini-3.1-pro" } debug: { model: "antigravity/gemini-3.1-pro" } tf-infra: { model: "antigravity/gemini-3.1-pro" } - explore: { model: "antigravity/gemini-3.5-flash" } + explore: { model: "antigravity/gemini-3.6-flash" } cursor: description: "Cursor — Cursor Pro / Pro Student" diff --git a/.agents/eval/oma-scholar/_rollouts/69cf97a7b7bfbc2f.json b/.agents/eval/oma-scholar/_rollouts/69cf97a7b7bfbc2f.json deleted file mode 100644 index 622d08c..0000000 --- a/.agents/eval/oma-scholar/_rollouts/69cf97a7b7bfbc2f.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "taskId": "anti-fabrication-doi", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":28130,\"duration_api_ms\":27860,\"ttft_ms\":15172,\"time_to_request_ms\":68,\"num_turns\":2,\"result\":\"My previous answer stands — look it up via CrossRef first, then fall back to `null` if genuinely unavailable. No further action needed from me here.\",\"stop_reason\":\"end_turn\",\"session_id\":\"f24a97d8-caf6-40b9-9444-60eff5aa8912\",\"total_cost_usd\":0.05763164999999999,\"usage\":{\"input_tokens\":20,\"cache_creation_input_tokens\":6663,\"cache_read_input_tokens\":34268,\"output_tokens\":1487,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":6663,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":20,\"outputTokens\":1487,\"cacheReadInputTokens\":34268,\"cacheCreationInputTokens\":6663,\"webSearchRequests\":0,\"costUSD\":0.05763164999999999,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"d01f3d85-c3ed-4c70-8797-2481a38a81ff\"}\n", - "score": 0 - }, - { - "taskId": "anti-fabrication-doi", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":6777,\"duration_api_ms\":6537,\"ttft_ms\":5691,\"time_to_request_ms\":71,\"num_turns\":1,\"result\":\"Omit the `doi` field entirely. The skill's anti-fabrication guardrail is explicit: if DOI/venue/year is not visible in the source, omit the key — never write `doi: TODO` or guess a value.\",\"stop_reason\":\"end_turn\",\"session_id\":\"cd373ab6-b617-42cf-94d7-8991f5208343\",\"total_cost_usd\":0.040792499999999995,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":8832,\"cache_read_input_tokens\":14675,\"output_tokens\":216,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":8832,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":10,\"outputTokens\":216,\"cacheReadInputTokens\":14675,\"cacheCreationInputTokens\":8832,\"webSearchRequests\":0,\"costUSD\":0.040792499999999995,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"5fa8d0fb-67d9-493a-af2c-775be01067a4\"}\n", - "score": 1 - }, - { - "taskId": "claims-only-partial-fetch", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":397326,\"duration_api_ms\":333895,\"ttft_ms\":4151,\"time_to_request_ms\":73,\"num_turns\":17,\"result\":\"No implementation work needed. The full answer stands:\\n\\n```bash\\noma scholar get knows:generated/reconvla/1.0.0 --section statements\\n```\\n\\nThen client-side:\\n```ts\\nconst claims = result.filter(s => s.statement_type === \\\"claim\\\");\\n```\\n\\nThe `--section` partial endpoint already exists (`api.ts:159`) — it's the only layer of granularity the API exposes. Claims-only filtering must be done client-side after the fetch.\",\"stop_reason\":\"end_turn\",\"session_id\":\"33f1a01b-9759-4035-96d5-27736de109c6\",\"total_cost_usd\":0.8089822000000002,\"usage\":{\"input_tokens\":110,\"cache_creation_input_tokens\":27008,\"cache_read_input_tokens\":384729,\"output_tokens\":6770,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":27008,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":110,\"outputTokens\":6770,\"cacheReadInputTokens\":384729,\"cacheCreationInputTokens\":27008,\"webSearchRequests\":0,\"costUSD\":0.3185787,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":247,\"outputTokens\":13876,\"cacheReadInputTokens\":3511765,\"cacheCreationInputTokens\":55680,\"webSearchRequests\":0,\"costUSD\":0.4904035000000002,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01NDNBepm2c3aDotjuwoVb4i\",\"tool_input\":{\"command\":\"git -C /private/tmp/claude-501/oma-eval-live-qRJXKN status 2>&1\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01Luu6aVLY5Qg6SMsvJYPpyA\",\"tool_input\":{\"command\":\"find /private/tmp/claude-501/-Users-gracefullight-workspace-* -type f \\\\( -name \\\"*.go\\\" -o -name \\\"*.proto\\\" -o -name \\\"*.ts\\\" -o -name \\\"*.js\\\" \\\\) 2>/dev/null | grep -v node_modules | head -30\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01Dw8us4i9SrQ3JZhEcQNNr3\",\"tool_input\":{\"command\":\"find /Users/gracefullight/.claude -name \\\"config.json\\\" 2>/dev/null | head -5\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_0164fTPtW4SMTGPFStX5zJ7i\",\"tool_input\":{\"command\":\"find /Users/gracefullight -type d -name \\\"oh-my-agent\\\" 2>/dev/null | head -10\",\"description\":\"Locate oh-my-agent directory\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01BSGZGfGDyAxtgFByvtCf7G\",\"tool_input\":{\"command\":\"find /Users/gracefullight -name \\\"*.proto\\\" -o -name \\\"*.openapi*\\\" -o -name \\\"*reconvla*\\\" 2>/dev/null | head -30\",\"description\":\"Search for proto, OpenAPI, and reconvla files\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01KJoZrJoUudwFcBrJDsCZSy\",\"tool_input\":{\"command\":\"find /Users/gracefullight -type d -name \\\"generated\\\" 2>/dev/null | head -20\",\"description\":\"Find all generated directories\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01E6x7WR236ZyLSXy6PYNYRc\",\"tool_input\":{\"command\":\"find /Users/gracefullight -type d -name \\\"oh-my-agent\\\" 2>/dev/null\",\"description\":\"Locate oh-my-agent directory\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01KxuVHDBzLWGmc6W7hfmGeU\",\"tool_input\":{\"command\":\"find /private/tmp -type f \\\\( -name \\\"*.ts\\\" -o -name \\\"*.js\\\" -o -name \\\"*.md\\\" \\\\) -exec grep -l \\\"sidecar\\\" {} \\\\; 2>/dev/null\",\"description\":\"Find files mentioning sidecar\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_013Xdi6UTyAx5NW5DhacGM7H\",\"tool_input\":{\"command\":\"find /private/tmp -type f \\\\( -name \\\"*.ts\\\" -o -name \\\"*.js\\\" -o -name \\\"*.md\\\" \\\\) -exec grep -l \\\"reconvla\\\" {} \\\\; 2>/dev/null\",\"description\":\"Find files mentioning reconvla\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_016oBZxCqyzsfj5HKUxHmBt1\",\"tool_input\":{\"command\":\"cd /private/tmp/oma-live-poc.bDZfBI && find . -type f \\\\( -name \\\"*.ts\\\" -o -name \\\"*.js\\\" -o -name \\\"*.json\\\" -o -name \\\"*.md\\\" \\\\) ! -path \\\"./node_modules/*\\\" ! -path \\\"./.git/*\\\" ! -path \\\"./.*/*\\\" 2>/dev/null | head -40\",\"description\":\"List non-config files in oma-live-poc\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01KMFPXR8tu6cs6HSUyocy33\",\"tool_input\":{\"command\":\"cd /private/tmp/oma-live-poc.bDZfBI && git log --all --oneline --grep=\\\"sidecar\\\\|reconvla\\\\|knows:\\\" 2>/dev/null | head -20\",\"description\":\"Search git history for keywords\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01PQGK9uhWLq9nbitKoEgX5p\",\"tool_input\":{\"command\":\"git -C /private/tmp/oma-live-poc.bDZfBI log --all --oneline --grep=\\\"sidecar\\\" 2>/dev/null | head -20\",\"description\":\"Search git history for sidecar\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01PvhDgucaWLDULWrsxKhLcN\",\"tool_input\":{\"command\":\"git -C /private/tmp/oma-live-poc.bDZfBI log --all --oneline --grep=\\\"reconvla\\\" 2>/dev/null | head -20\",\"description\":\"Search git history for reconvla\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_016RrRB6tZixoA2kFEsURxUH\",\"tool_input\":{\"command\":\"git -C /private/tmp/oma-live-poc.bDZfBI log --all --oneline --grep=\\\"knows\\\" 2>/dev/null | head -20\",\"description\":\"Search git history for knows\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_01KGPbpKAUkKzCmRYnFrxhTd\",\"tool_input\":{\"command\":\"git -C /private/tmp/oma-live-poc.bDZfBI log --oneline -20\",\"description\":\"View recent git history\"}},{\"tool_name\":\"ExitPlanMode\",\"tool_use_id\":\"toolu_01RgfVc8nxcRQBdtZT5MKjTn\",\"tool_input\":{\"plan\":\"# Context\\n\\nThe user wants only the `statement_type: claim` statements from a knows sidecar (`knows:generated/reconvla/1.0.0`), with minimal token overhead. No code change is needed — this is answered by the existing `--section` partial-fetch flag.\\n\\n## Answer\\n\\nThe partial-fetch endpoint exists for this exact purpose. Use:\\n\\n```bash\\noma scholar get knows:generated/reconvla/1.0.0 --section statements\\n```\\n\\nThis hits `knows.academy/api/proxy/partial?record_id=…§ion=statements` (see `cli/commands/scholar/api.ts:159`), skipping the full sidecar (artifacts, evidence, relations, summary, provenance). Only the `statements` array is returned.\\n\\nThen filter client-side for claims only:\\n\\n```ts\\nconst claims = statements.filter(s => s.statement_type === \\\"claim\\\");\\n```\\n\\nClaims are ~46% of all statements (117/252 across 15 production sidecars per `sidecar-spec.md:81`), so `--section statements` + client filter is the minimum viable fetch.\\n\\n## No code changes required\\n\\nThe `--section` flag is already implemented in:\\n- `cli/commands/scholar/index.ts:56` — CLI option registration\\n- `cli/commands/scholar/get.ts:22` — passed through to `fetchKnowsSidecar`\\n- `cli/commands/scholar/api.ts:153` — hits the partial endpoint\\n\\n## Verification\\n\\nRun the command above; confirm the JSON output contains only the `statements` array, with no `evidence`, `artifacts`, or `relations` keys.\\n\",\"planFilePath\":\"/Users/gracefullight/.claude/plans/i-only-need-the-squishy-perlis.md\"}}],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"3a161241-2bd5-4a4e-870a-2185c13775b3\"}\n", - "score": 1 - }, - { - "taskId": "claims-only-partial-fetch", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":21481,\"duration_api_ms\":21224,\"ttft_ms\":3139,\"time_to_request_ms\":86,\"num_turns\":2,\"result\":\"Use the partial fetch with `--section statements`:\\n\\n```bash\\noma scholar get --section statements \\\"knows:generated/reconvla/1.0.0\\\"\\n```\\n\\nThis fetches only the `statements` block (~700 tokens vs ~10K for a full PDF — ~93% reduction). If the CLI isn't available, the equivalent raw API call is:\\n\\n```bash\\ncurl -s \\\"https://knows.academy/api/proxy/partial?record_id=knows:generated/reconvla/1.0.0§ion=statements\\\"\\n```\\n\\nThe `section` parameter accepts a fixed enum: `statements`, `evidence`, `relations`, `artifacts`, or `citation`. Use `statements` for claims only.\",\"stop_reason\":\"end_turn\",\"session_id\":\"911565a3-70eb-4311-b323-625850f88307\",\"total_cost_usd\":0.062139749999999994,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":9061,\"cache_read_input_tokens\":38190,\"output_tokens\":1110,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":9061,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":18,\"outputTokens\":1110,\"cacheReadInputTokens\":38190,\"cacheCreationInputTokens\":9061,\"webSearchRequests\":0,\"costUSD\":0.062139749999999994,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"f87a7e54-4d68-4cec-9555-f7d002d382b3\"}\n", - "score": 1 - }, - { - "taskId": "lint-before-share", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":150356,\"duration_api_ms\":147570,\"ttft_ms\":2306,\"time_to_request_ms\":159,\"num_turns\":7,\"result\":\"The audit found four issues in `attention-is-all-you-need.knows.yaml` against the v0.9.0 checklist:\\n\\n| # | Issue | Severity |\\n|---|-------|----------|\\n| 1 | `stmt:transformer-architecture` is orphaned — no relations at all (method type requires ≥1 relation) | Blocker |\\n| 2 | Relation density 1.0 (3/3), must be ≥ 1.5 — need at least 2 more relations | Blocker |\\n| 3 | No `rep:` artifact and no `source_anchors` on any statement | Recommended |\\n| 4 | `venue`/`year` missing — add only if visible in your source | Minor/conditional |\\n\\nAfter fixes, run `oma scholar lint attention-is-all-you-need.knows.yaml` to confirm 0 errors before sharing.\",\"stop_reason\":\"end_turn\",\"session_id\":\"33a5bbb1-2a63-45ec-aa29-e02e9470aaa1\",\"total_cost_usd\":0.2940157,\"usage\":{\"input_tokens\":52,\"cache_creation_input_tokens\":16324,\"cache_read_input_tokens\":144744,\"output_tokens\":3864,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":16324,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":52,\"outputTokens\":3864,\"cacheReadInputTokens\":144744,\"cacheCreationInputTokens\":16324,\"webSearchRequests\":0,\"costUSD\":0.16275420000000002,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":281,\"outputTokens\":5977,\"cacheReadInputTokens\":604755,\"cacheCreationInputTokens\":32496,\"webSearchRequests\":0,\"costUSD\":0.1312615,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[{\"tool_name\":\"ExitPlanMode\",\"tool_use_id\":\"toolu_01TLcz9vT5LKgmKPtdC4BkvP\",\"tool_input\":{\"allowedPrompts\":[{\"tool\":\"Bash\",\"prompt\":\"run oma scholar lint\"}],\"plan\":\"# Plan: Pre-Share Fixes for attention-is-all-you-need.knows.yaml\\n\\n## Context\\n\\nThe sidecar was generated via `oma scholar` Mode 1 (Generate). Before sharing, the post-generation checklist (`resources/checklist.md`) must pass. An audit of the current file (`attention-is-all-you-need.knows.yaml`) against the v0.9.0 spec found four concrete issues that must be fixed, plus one lint run required.\\n\\n---\\n\\n## Issues Found\\n\\n### 1. Orphan statement — `stmt:transformer-architecture` (BLOCKER)\\n\\nNo relations reference this statement (incoming or outgoing). As a `method` type, it requires at least one of: `implements`, `uses`, `evaluates_on`, `documents`.\\n\\n**Fix:** Add a relation wiring it to the evidence, e.g.:\\n```yaml\\n- id: rel:architecture-documents-paper\\n predicate: documents\\n subject_ref: stmt:transformer-architecture\\n object_ref: art:paper\\n```\\nOr wire it to the two BLEU evidence items with `evaluates_on`.\\n\\n### 2. Relation density below threshold (BLOCKER)\\n\\nCurrent: 3 relations / 3 statements = **1.0**. Required: **≥ 1.5** (needs ≥ 5 relations for 3 statements).\\n\\n**Fix:** Adding 2 relations for `stmt:transformer-architecture` (e.g., `documents` + `evaluates_on`) brings the total to 5 and the ratio to 1.67 ✓.\\n\\n### 3. No `source_anchors` and no `rep:` artifact (RECOMMENDED)\\n\\nChecklist item: every statement's `source_anchors` should reference a `rep:` artifact. No representation artifact exists in the file.\\n\\n**Fix:** Add a `rep:` entry under `artifacts` and attach `source_anchors` to each statement:\\n```yaml\\n# In artifacts:\\n- id: rep:paper-pdf\\n artifact_type: paper\\n role: supporting\\n title: \\\"Attention Is All You Need (arXiv 1706.03762)\\\"\\n\\n# In each statement:\\nsource_anchors:\\n - representation_ref: rep:paper-pdf\\n locator_type: section\\n locator: \\\"Abstract\\\" # or \\\"Section 6\\\" etc.\\n```\\n\\n### 4. Missing `venue` and `year` (MINOR — anti-fabrication gate)\\n\\nOnly add these if they were visible in the source you used to generate the sidecar. If yes:\\n```yaml\\nvenue: \\\"NeurIPS 2017\\\"\\nyear: 2017\\n```\\nIf the source was the abstract only (no header/footer visible), leave them out — do not add from memory.\\n\\n---\\n\\n## Steps\\n\\n1. **Edit the file** (`attention-is-all-you-need.knows.yaml`):\\n - Add `rel:architecture-documents-paper` (or equivalent) and at least one more relation for `stmt:transformer-architecture`\\n - Add `rep:paper-pdf` artifact\\n - Add `source_anchors` to all three statements\\n - Add `venue`/`year` only if visible in original source\\n\\n2. **Run lint:**\\n ```bash\\n oma scholar lint attention-is-all-you-need.knows.yaml\\n ```\\n Must return 0 errors. Review any warnings.\\n\\n3. **Verify final metrics** before sharing:\\n - Statements: 3, Evidence: 3, Relations: ≥ 5, Artifacts: ≥ 2\\n - Ratio: relations/statements ≥ 1.5\\n - Lint: clean\\n\\n---\\n\\n## Files to Modify\\n\\n- `/Users/gracefullight/workspace/oh-my-agent/attention-is-all-you-need.knows.yaml`\\n\\n## Reference Files\\n\\n- Checklist: `.agents/skills/oma-scholar/resources/checklist.md`\\n- Spec: `.agents/skills/oma-scholar/resources/sidecar-spec.md`\\n\",\"planFilePath\":\"/Users/gracefullight/.claude/plans/i-generated-a-paper-knows-yaml-moonlit-music.md\"}}],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"d1791f5a-cb46-452e-8933-25dee5c10e65\"}\n", - "score": 1 - }, - { - "taskId": "lint-before-share", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":9176,\"duration_api_ms\":8766,\"ttft_ms\":4224,\"time_to_request_ms\":181,\"num_turns\":1,\"result\":\"Before sharing your generated sidecar, run the linter:\\n\\n```bash\\noma scholar lint paper.knows.yaml\\n```\\n\\nFor CI or strict workflows, treat warnings as failures:\\n\\n```bash\\noma scholar lint --fail-on-warning paper.knows.yaml\\n```\\n\\n**What to fix if it fails:**\\n\\n| Error | Fix |\\n|-------|-----|\\n| `numeric value '22' is quoted` | Remove quotes: `value: 22` |\\n| `actor.type: 'ai' is not allowed` | Change to `tool`, `person`, or `org` |\\n| `use \\\\`statement_type\\\\` instead of \\\\`type\\\\`` | Rename the field |\\n| `provenance.actors` array found | Replace with singular `actor: {...}` object |\\n| Dangling `subject_ref`/`object_ref` | Fix IDs to match defined statement/evidence IDs |\\n\\n**Warnings to address:**\\n\\n- **Relation density below 1.5/statement** — add `supported_by` or `depends_on` relations\\n- **Fewer than 8 statements** — expected if generated from abstract only; full-paper sidecars should have 15+\\n- **Past-tense predicates** — switch to present tense (e.g. `evaluates_on` not `evaluated_on`)\\n\\nOnce `oma scholar lint` passes cleanly, the sidecar is ready to share.\",\"stop_reason\":\"end_turn\",\"session_id\":\"44a61db5-76a9-448f-a8dd-10f7251c76c4\",\"total_cost_usd\":0.044531249999999994,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":8829,\"cache_read_input_tokens\":14675,\"output_tokens\":466,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":8829,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":10,\"outputTokens\":466,\"cacheReadInputTokens\":14675,\"cacheCreationInputTokens\":8829,\"webSearchRequests\":0,\"costUSD\":0.044531249999999994,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"2224d21a-3a03-4093-a43a-d7405c105304\"}\n", - "score": 1 - }, - { - "taskId": "openalex-fallback-old-paper", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":110497,\"duration_api_ms\":131078,\"ttft_ms\":8745,\"time_to_request_ms\":219,\"num_turns\":5,\"result\":\"Here's what the research found:\\n\\n**\\\"Attention Is All You Need\\\" (Vaswani et al., 2017)**\\n\\nThe paper is freely available at:\\n- **arXiv (canonical):** https://arxiv.org/abs/1706.03762\\n- **NeurIPS PDF:** https://papers.neurips.cc/paper/7181-attention-is-all-you-need.pdf\\n\\n**knows.academy — No.**\\n\\nknows.academy is a digital marketing academy for small business owners (also at knowsacademy.com). Its curriculum is entirely focused on marketing education, not ML research. The paper does not appear there.\",\"stop_reason\":\"end_turn\",\"session_id\":\"e726efed-589d-4029-be52-0d22a82ee1d8\",\"total_cost_usd\":0.31598799999999994,\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":9204,\"cache_read_input_tokens\":76980,\"output_tokens\":2218,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":9204,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":38,\"outputTokens\":2218,\"cacheReadInputTokens\":76980,\"cacheCreationInputTokens\":9204,\"webSearchRequests\":0,\"costUSD\":0.09099299999999999,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":89209,\"outputTokens\":4473,\"cacheReadInputTokens\":286635,\"cacheCreationInputTokens\":11806,\"webSearchRequests\":7,\"costUSD\":0.22499499999999997,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[{\"tool_name\":\"mcp__chrome-devtools__new_page\",\"tool_use_id\":\"toolu_0175xEiaSdfqZxZ7DmN2LJUw\",\"tool_input\":{\"url\":\"https://knows.academy\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01U4FTC3YHnS5DqEXVWDsDx2\",\"tool_input\":{\"url\":\"https://knows.academy\",\"prompt\":\"What is this website about? Does it contain any content related to the 2017 \\\"Attention Is All You Need\\\" Transformer paper by Vaswani et al.?\"}}],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"bfec8cbf-f428-49bc-9d07-479241862544\"}\n", - "score": 0 - }, - { - "taskId": "openalex-fallback-old-paper", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":186179,\"duration_api_ms\":183479,\"ttft_ms\":5670,\"time_to_request_ms\":217,\"num_turns\":8,\"result\":\"Since this is a research task (not code implementation), let me answer directly from my exploration.\\n\\n---\\n\\n**No — knows.academy will not have it.**\\n\\n`knows.academy` currently indexes only ~50,000 papers from **2026** (mostly arXiv). The \\\"Attention Is All You Need\\\" Transformer paper (Vaswani et al., 2017, arXiv:1706.03762) predates the index by nine years and will return no hits.\\n\\nThe `oma scholar` CLI handles this automatically via a **knows.academy → OpenAlex cascade**:\\n\\n```bash\\noma scholar resolve \\\"Attention Is All You Need\\\"\\n# knows.academy: no hit\\n# → falls back to OpenAlex (~240M works, all years)\\n# → returns title, authors, year, venue (NeurIPS 2017), DOI, abstract, OA PDF URL, citation count\\n```\\n\\nOr fetch directly by DOI:\\n\\n```bash\\noma scholar get \\\"10.48550/arXiv.1706.03762\\\"\\n```\\n\\nThe result will be marked `source: \\\"openalex\\\"`. There's also a pre-existing sidecar already in the repo at:\\n\\n```\\n/Users/gracefullight/workspace/oh-my-agent/attention-is-all-you-need.knows.yaml\\n```\\n\\nYou may want to validate or reuse that rather than regenerating:\\n\\n```bash\\noma scholar lint --lenient attention-is-all-you-need.knows.yaml\\n```\\n\\n**Summary of coverage:**\\n\\n| Source | Has this paper? | Gives |\\n|--------|----------------|-------|\\n| knows.academy | No (2026-only) | — |\\n| OpenAlex | Yes | Metadata + abstract (no structured sidecar) |\\n| Local repo | Yes | Pre-existing `.knows.yaml` sidecar |\",\"stop_reason\":\"end_turn\",\"session_id\":\"33311bfd-df8e-4288-ab9a-1707cfc70eaf\",\"total_cost_usd\":0.34734725,\"usage\":{\"input_tokens\":62,\"cache_creation_input_tokens\":21857,\"cache_read_input_tokens\":194039,\"output_tokens\":5633,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":21857,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":62,\"outputTokens\":5633,\"cacheReadInputTokens\":194039,\"cacheCreationInputTokens\":21857,\"webSearchRequests\":0,\"costUSD\":0.22485645,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":1151,\"outputTokens\":5285,\"cacheReadInputTokens\":540323,\"cacheCreationInputTokens\":32706,\"webSearchRequests\":0,\"costUSD\":0.12249080000000001,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[{\"tool_name\":\"ExitPlanMode\",\"tool_use_id\":\"toolu_01VGcZzG6CACjeUqUFYa6aqp\",\"tool_input\":{\"plan\":\"# Context\\n\\nThe user wants to find the 2017 \\\"Attention Is All You Need\\\" Transformer paper using the oma-scholar skill. The key sub-question is whether `knows.academy` indexes it. The answer is **no**: knows.academy covers only ~50K papers from **2026** (arXiv-mostly). The 2017 Transformer paper requires an automatic fallback to **OpenAlex** (~240M works, all years).\\n\\nThere is also an existing `attention-is-all-you-need.knows.yaml` file already in the repo at:\\n`/Users/gracefullight/workspace/oh-my-agent/attention-is-all-you-need.knows.yaml`\\n\\n# Answer to \\\"Will knows.academy have it?\\\"\\n\\n**No.** knows.academy is a 2026-only index. The Transformer paper (arXiv:1706.03762, published June 2017) is not indexed there. The `oma scholar` CLI cascades automatically to OpenAlex for all pre-2026 work.\\n\\n# Execution Plan (Mode 6 → Remote with OpenAlex Fallback)\\n\\n## Step 1 — Resolve via CLI (recommended path)\\n\\n```bash\\noma scholar resolve \\\"Attention Is All You Need\\\"\\n```\\n\\nQueries knows.academy first → no hit → auto-falls back to OpenAlex → returns:\\n- Title, authors, year (2017), venue (NeurIPS), DOI, abstract, OA PDF URL, citation count\\n- Result marked `source: \\\"openalex\\\"`, `fallback: \\\"openalex\\\"`\\n\\n## Step 2 — Direct fetch by DOI (alternative)\\n\\n```bash\\noma scholar get \\\"10.48550/arXiv.1706.03762\\\"\\n# or by OpenAlex W-id\\noma scholar get \\\"W2147144213\\\"\\n```\\n\\n## Step 3 — (Optional) Generate a local sidecar from the abstract\\n\\nIf the user wants a structured `.knows.yaml` sidecar, chain into Mode 1 Generate:\\n\\n```bash\\n# 1. Get metadata + abstract from OpenAlex (done in Step 1/2)\\n# 2. Optionally download OA PDF and extract full text\\noma-pdf \\n# 3. Generate sidecar\\n# (host LLM emits .knows.yaml using sidecar-spec.md rules)\\n# 4. Lint\\noma scholar lint attention-is-all-you-need.knows.yaml\\n```\\n\\nNote: A pre-existing `attention-is-all-you-need.knows.yaml` exists in the repo.\\nConsider whether to validate/reuse it rather than regenerating.\\n\\n# Critical Files\\n\\n| File | Purpose |\\n|------|---------|\\n| `cli/commands/scholar/resolve.ts` | Cross-source resolve command (knows → OpenAlex cascade) |\\n| `cli/commands/scholar/api.ts` | knows.academy + OpenAlex API integration |\\n| `cli/commands/scholar/get.ts` | Fetch by DOI/W-id/knows record_id |\\n| `attention-is-all-you-need.knows.yaml` | Pre-existing sidecar to inspect/reuse |\\n| `.agents/skills/oma-scholar/resources/fallback-providers.md` | Cascade design reference |\\n| `.agents/skills/oma-scholar/resources/execution-protocol.md` | Mode 6 steps |\\n\\n# Verification\\n\\n1. Run `oma scholar resolve \\\"Attention Is All You Need\\\"` → confirm `source: openalex` in response\\n2. Run `oma scholar get \\\"10.48550/arXiv.1706.03762\\\"` → confirm metadata returned with abstract\\n3. If generating sidecar: `oma scholar lint attention-is-all-you-need.knows.yaml` → 0 errors\\n4. Check `/api/proxy/jobs/stats` to confirm knows.academy health if any API calls time out\\n\",\"planFilePath\":\"/Users/gracefullight/.claude/plans/name-oma-scholar-description-steady-bentley.md\"}}],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"d52dff6d-cbe8-407a-ad9b-49834cc5ca97\"}\n", - "score": 1 - }, - { - "taskId": "provenance-single-actor", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":924462,\"duration_api_ms\":1312827,\"ttft_ms\":21428,\"time_to_request_ms\":1061,\"num_turns\":7,\"result\":\"I wasn't able to find the answer after searching across SLSA, in-toto, iterum-provenance/sidecar, Grafeas, W3C PROV, CycloneDX, Argo, and Sigstore.\\n\\nCould you share which specific project or specification you mean by \\\"v0.9.0 sidecar\\\"? A repo URL or tool name would let me look it up directly and give you a precise answer.\",\"stop_reason\":\"end_turn\",\"session_id\":\"a0eaa9af-50a0-40ed-b13f-8c88294b8fca\",\"total_cost_usd\":3.7969037999999986,\"usage\":{\"input_tokens\":62,\"cache_creation_input_tokens\":16516,\"cache_read_input_tokens\":163898,\"output_tokens\":7499,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":16516,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":62,\"outputTokens\":7499,\"cacheReadInputTokens\":163898,\"cacheCreationInputTokens\":16516,\"webSearchRequests\":0,\"costUSD\":0.2237754,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":1743885,\"outputTokens\":68445,\"cacheReadInputTokens\":3380134,\"cacheCreationInputTokens\":119204,\"webSearchRequests\":100,\"costUSD\":3.573128399999998,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01REFs2ug1ejFJdVXKxxEi9B\",\"tool_input\":{\"url\":\"https://slsa.dev/spec/v1.0/distributing-provenance\",\"prompt\":\"sidecar format v0.9.0 actor field schema specification\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01DwyUWXaq3NoNZn7hEGZWLc\",\"tool_input\":{\"url\":\"https://slsa.dev/spec/v1.0/provenance\",\"prompt\":\"actor field schema single object or array\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01SGWp5dpySNgYCG9ETNfv76\",\"tool_input\":{\"url\":\"http://openlineage.io/docs/1.47.0/releases/0_9_0/\",\"prompt\":\"actor sidecar schema field single object array\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_012JcjNLp9oeNngCskEPAWDR\",\"tool_input\":{\"url\":\"https://slsa.dev/spec/v0.1/provenance\",\"prompt\":\"Find the actor field structure\"}},{\"tool_name\":\"Bash\",\"tool_use_id\":\"toolu_019ZRaCqdUqAujN2CS4XNVPD\",\"tool_input\":{\"command\":\"curl -s \\\"https://api.github.com/search/code?q=sidecar+provenance+actor&type=code\\\" 2>&1 | head -50\",\"description\":\"Search GitHub API for code with sidecar, provenance, and actor\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01P22m3oUVW2Cej64ELbZ44i\",\"tool_input\":{\"url\":\"https://slsa.dev/spec/v1.0/provenance\",\"prompt\":\"Look for any actor field definitions in the SLSA provenance specification\"}},{\"tool_name\":\"WebFetch\",\"tool_use_id\":\"toolu_01Wkf4QC24EvZVaD2WTR15e5\",\"tool_input\":{\"url\":\"https://pypi.org/project/sidecar/\",\"prompt\":\"Look for version 0.9.0 and any provenance or actor field information\"}},{\"tool_name\":\"AskUserQuestion\",\"tool_use_id\":\"toolu_01V2X9cvwjhswjeagyLYz9et\",\"tool_input\":{\"questions\":[{\"question\":\"Which project or specification does \\\"v0.9.0 sidecar\\\" refer to?\",\"header\":\"Project\",\"options\":[{\"label\":\"iterum-provenance/sidecar\",\"description\":\"The Go-based data-pipeline provenance sidecar from the Iterum project on GitHub.\"},{\"label\":\"A supply-chain/SLSA tool (e.g. Tekton Chains, Witness, Chainloop)\",\"description\":\"A CI/CD or build-provenance tool that emits SLSA-style attestations via a sidecar component.\"},{\"label\":\"A container/Kubernetes sidecar\",\"description\":\"A sidecar container in a Kubernetes pod that captures provenance (e.g. Sigstore policy controller, cosign sidecar).\"},{\"label\":\"Other / I'll provide the repo or spec link\",\"description\":\"A different project — please share the repository URL, spec link, or tool name so I can look it up directly.\"}],\"multiSelect\":false}]}}],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"01a1ab82-6027-47d3-979f-ecd750135088\"}\n", - "score": 0 - }, - { - "taskId": "provenance-single-actor", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":11921,\"duration_api_ms\":11698,\"ttft_ms\":10590,\"time_to_request_ms\":94,\"num_turns\":1,\"result\":\"In v0.9.0, `provenance.actor` is a **single object**, not a list.\\n\\n```yaml\\nprovenance:\\n actor:\\n type: tool # tool | person | org (never \\\"ai\\\" / \\\"llm\\\" / \\\"model\\\")\\n name: \\\"...\\\"\\n```\\n\\nUsing a `provenance.actors` array is a schema error — the spec intentionally uses the singular key `actor`. This is Guardrail #6 in the skill spec and also one of the common lint errors:\\n\\n```\\n[ERROR] provenance.actors: v0.9 spec uses singular `actor`\\n```\",\"stop_reason\":\"end_turn\",\"session_id\":\"912229b6-78fb-4c30-a426-1c897459d6f0\",\"total_cost_usd\":0.04575375,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":8835,\"cache_read_input_tokens\":14675,\"output_tokens\":546,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":8835,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":10,\"outputTokens\":546,\"cacheReadInputTokens\":14675,\"cacheCreationInputTokens\":8835,\"webSearchRequests\":0,\"costUSD\":0.04575375,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"e5ef461e-f2b6-4ef6-a3e4-5a23c3740138\"}\n", - "score": 1 - }, - { - "taskId": "spec-version-profile", - "arm": "baseline", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":86554,\"duration_api_ms\":76443,\"ttft_ms\":2845,\"time_to_request_ms\":193,\"num_turns\":3,\"result\":\"The answer stands — `spec v0.9.0` / `profile paper@1`. No further action needed from me on this one.\",\"stop_reason\":\"end_turn\",\"session_id\":\"9e064cf1-8192-463a-89f7-07c3f8a0e866\",\"total_cost_usd\":0.20600234999999997,\"usage\":{\"input_tokens\":28,\"cache_creation_input_tokens\":8281,\"cache_read_input_tokens\":55267,\"output_tokens\":1258,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":8281,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":28,\"outputTokens\":1258,\"cacheReadInputTokens\":55267,\"cacheCreationInputTokens\":8281,\"webSearchRequests\":0,\"costUSD\":0.06658785,\"contextWindow\":200000,\"maxOutputTokens\":32000},\"claude-haiku-4-5-20251001\":{\"inputTokens\":41,\"outputTokens\":3274,\"cacheReadInputTokens\":913060,\"cacheCreationInputTokens\":25358,\"webSearchRequests\":0,\"costUSD\":0.1394145,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"ea81ee81-56b8-4c05-9f5f-fc15a8547dc5\"}\n", - "score": 1 - }, - { - "taskId": "spec-version-profile", - "arm": "treatment", - "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"api_error_status\":null,\"duration_ms\":7027,\"duration_api_ms\":6788,\"ttft_ms\":4301,\"time_to_request_ms\":97,\"num_turns\":1,\"result\":\"Target **v0.9.0** with the **`paper@1`** profile.\\n\\nThis is the production-verified combination indexed by knows.academy and validated by `oma scholar lint`. The guardrails in the skill spec note that `resources/sidecar-spec.md` tracks this version, and that the upstream `knows.md` natural-language description can drift from actual v0.9.0 production behavior — so always defer to the local spec rules when there's a conflict.\",\"stop_reason\":\"end_turn\",\"session_id\":\"de045d5f-cb88-45d7-8d21-a7cf77f6315c\",\"total_cost_usd\":0.04119375,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":8827,\"cache_read_input_tokens\":14675,\"output_tokens\":244,\"server_tool_use\":{\"web_search_requests\":0,\"web_fetch_requests\":0},\"service_tier\":\"standard\",\"cache_creation\":{\"ephemeral_1h_input_tokens\":8827,\"ephemeral_5m_input_tokens\":0},\"inference_geo\":\"not_available\",\"iterations\":[],\"speed\":\"standard\"},\"modelUsage\":{\"claude-sonnet-4-6\":{\"inputTokens\":10,\"outputTokens\":244,\"cacheReadInputTokens\":14675,\"cacheCreationInputTokens\":8827,\"webSearchRequests\":0,\"costUSD\":0.04119375,\"contextWindow\":200000,\"maxOutputTokens\":32000}},\"permission_denials\":[],\"terminal_reason\":\"completed\",\"fast_mode_state\":\"off\",\"uuid\":\"4b84c7d4-5c5e-4a89-9f3c-ac536721cbb1\"}\n", - "score": 1 - } -] \ No newline at end of file diff --git a/.agents/eval/oma-scholar/anti-fabrication-doi.yaml b/.agents/eval/oma-scholar/anti-fabrication-doi.yaml deleted file mode 100644 index 64b631e..0000000 --- a/.agents/eval/oma-scholar/anti-fabrication-doi.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: anti-fabrication-doi -skill: oma-scholar -domain: research -prompt: "The DOI is not visible anywhere in the source PDF. What do I put in the sidecar's doi field?" -checker: - type: judge - rubric: "The answer says to OMIT the doi key entirely (never write doi: TODO and never guess). FAIL if it suggests a placeholder, TODO, or fabricating/guessing the DOI." -weight: 1 diff --git a/.agents/eval/oma-scholar/claims-only-partial-fetch.yaml b/.agents/eval/oma-scholar/claims-only-partial-fetch.yaml deleted file mode 100644 index bea041a..0000000 --- a/.agents/eval/oma-scholar/claims-only-partial-fetch.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: claims-only-partial-fetch -skill: oma-scholar -domain: research -prompt: "I only need the claims of the sidecar knows:generated/reconvla/1.0.0, token-efficiently. How?" -checker: - type: judge - rubric: "The answer uses oma-scholar's PARTIAL fetch with the SINGULAR section param (section=statements) to retrieve only the claims (e.g. `oma scholar get --section statements ...`), rather than fetching the full sidecar. Mentioning the ~700-token / claims-only efficiency is a plus. FAIL if it suggests fetching the whole PDF/sidecar or uses a wrong/plural param." -weight: 1 diff --git a/.agents/eval/oma-scholar/lint-before-share.yaml b/.agents/eval/oma-scholar/lint-before-share.yaml deleted file mode 100644 index c35c15e..0000000 --- a/.agents/eval/oma-scholar/lint-before-share.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: lint-before-share -skill: oma-scholar -domain: research -prompt: "I generated a paper.knows.yaml sidecar. What should I do before sharing it?" -checker: - type: judge - rubric: "The answer recommends validating with `oma scholar lint` (and ideally notes --lenient for third-party sidecars). FAIL if it only says 'check the YAML' generically without the oma scholar lint step." -weight: 1 diff --git a/.agents/eval/oma-scholar/openalex-fallback-old-paper.yaml b/.agents/eval/oma-scholar/openalex-fallback-old-paper.yaml deleted file mode 100644 index caeb44c..0000000 --- a/.agents/eval/oma-scholar/openalex-fallback-old-paper.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: openalex-fallback-old-paper -skill: oma-scholar -domain: research -prompt: "Find the 2017 'Attention Is All You Need' Transformer paper. Will knows.academy have it?" -checker: - type: judge - rubric: "The answer explains knows.academy indexes only 2026 papers, so for a 2017 paper it falls back to OpenAlex for metadata/abstract. FAIL if it claims knows.academy has the 2017 paper or omits the OpenAlex fallback." -weight: 1 diff --git a/.agents/eval/oma-scholar/provenance-single-actor.yaml b/.agents/eval/oma-scholar/provenance-single-actor.yaml deleted file mode 100644 index 298b33a..0000000 --- a/.agents/eval/oma-scholar/provenance-single-actor.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: provenance-single-actor -skill: oma-scholar -domain: research -prompt: "How is the provenance actor represented in a v0.9.0 sidecar — one actor or a list?" -checker: - type: judge - rubric: "The answer states provenance.actor is a SINGLE object (not a provenance.actors array), and its type is one of tool|person|org (never ai/llm/model). FAIL if it says an array of actors or allows an 'ai'/'llm' type." -weight: 1 diff --git a/.agents/eval/oma-scholar/spec-version-profile.yaml b/.agents/eval/oma-scholar/spec-version-profile.yaml deleted file mode 100644 index b58e19e..0000000 --- a/.agents/eval/oma-scholar/spec-version-profile.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: spec-version-profile -skill: oma-scholar -domain: research -prompt: "Which sidecar spec version and profile should a brand-new .knows.yaml target?" -checker: - type: judge - rubric: "The answer states spec v0.9.0 and profile paper@1 (subject_ref art:paper is a plus). FAIL if it gives a different version/profile or is vague." -weight: 1 diff --git a/.agents/hooks/core/agentmemory-client.ts b/.agents/hooks/core/agentmemory-client.ts index 5816663..decf646 100644 --- a/.agents/hooks/core/agentmemory-client.ts +++ b/.agents/hooks/core/agentmemory-client.ts @@ -5,10 +5,7 @@ import http from "node:http"; 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)\./; +import { currentMemoryAdapter } from "./memory-adapter.ts"; function endpointUrl(): string | null { if (process.env.OMA_NO_AGENTMEMORY === "1") return null; @@ -32,6 +29,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 +103,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 +124,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 +194,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() @@ -196,17 +234,30 @@ function parseSearchResults(body: string, k: number): RecalledFact[] { export async function recallFacts( query: string, k = 5, + projectDir: string = process.cwd(), ): 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 { + const adapter = currentMemoryAdapter(projectDir); + if (adapter) return await adapter.recall(query, k, projectDir); + if (!(await isAgentMemoryReachable())) return []; + const url = endpointUrl(); + if (!url) return []; const response = await requestAgentMemory(url, "/agentmemory/search", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ query, limit: k }), + // Match the project identity used by observeWithTimeout. The query's + // project-name term is a relevance hint, not a scope restriction. + body: JSON.stringify({ + query, + limit: k, + project: basename(projectDir), + cwd: projectDir, + }), timeoutMs: 2000, }); if (response.statusCode < 200 || response.statusCode >= 300) return []; @@ -222,11 +273,18 @@ 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 { + const adapter = currentMemoryAdapter(payload.projectDir); + if (adapter) return await adapter.observe(payload); + // An explicit opt-out leaves no observation to retry. Injected adapters + // still own their delivery policy, including non-AgentMemory providers. + if (process.env.OMA_NO_AGENTMEMORY === "1") return true; + 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/code-intelligence-guard.ts b/.agents/hooks/core/code-intelligence-guard.ts new file mode 100644 index 0000000..26e585c --- /dev/null +++ b/.agents/hooks/core/code-intelligence-guard.ts @@ -0,0 +1,473 @@ +// PreToolUse hook — Deny native code search when a code-intelligence provider +// (Serena / Gortex) is configured, so the CLAUDE.md "Code Search" rule is +// enforced mechanically instead of relying on model compliance. +// Works with: Claude Code, Codex CLI, Cursor, Grok, Kimi, Kiro, Qwen Code. +// +// Scope decisions: +// - Native searches confined to provider-excluded or external paths pass. +// Other `Grep` / `Glob` calls use the provider. `Read` / `LS` +// style tools are never touched — the provider contract is about discovery, +// not reading. +// - Shell commands are denied only when a segment's leading command is a +// recursive code search: `rg` / `ag` / `ack` / `fd`, `grep` with a +// recursive flag, `find` with a name/path predicate, or `git grep`. +// Non-recursive `grep` (filtering a pipe / a single file) and `find` without +// a name predicate pass through — they are not discovery. +// - Gating: only fires when `providers.code_intelligence` resolves (yaml or +// `.serena/project.yml`) and `providers.code_intelligence_guard` is not +// `off`. The hook cannot observe whether the MCP server is actually up. +// The deny reason does not name a bypass: confirmed exclusions and +// external paths already pass, and project source stays on the provider. +// - Escape hatch: a shell command containing `OMA_CI_ALLOW_NATIVE=1` still +// bypasses the guard. That prefix is only for a search of resources +// outside the project or ignored paths the guard did not recognize, never +// for project source. Do not advertise it in the deny reason. Grep/Glob +// have no argument to carry a token, so the hatch stays shell-only. + +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { + type CodeIntelligenceProvider, + detectCodeIntelligenceGuardMode, + detectCodeIntelligenceProvider, +} from "./code-intelligence-primer.ts"; +import { + isExcludedSearchScope, + searchPathRoot, +} from "./code-intelligence-scope.ts"; +import { makePreToolDenyOutput } from "./hook-output.ts"; +import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { getProjectDir } from "./vendor-detect.ts"; + +export const BYPASS_TOKEN = "OMA_CI_ALLOW_NATIVE=1"; + +// --- Tool classification --- + +/** + * Native search tools a provider replaces. Claude Code names only: the other + * wired vendors register this handler under their shell-tool matcher, so their + * native search tools (e.g. Qwen `grep_search`) never reach the chain and are + * deliberately not listed — enforcement there is the shell branch only. + */ +const GREP_TOOLS = new Set(["Grep"]); +const GLOB_TOOLS = new Set(["Glob"]); + +/** Shell tools across the wired vendors (same set as scm-guard / test-filter). */ +const SHELL_TOOLS = new Set([ + "Bash", + "run_shell_command", + "Shell", + "execute_bash", +]); + +// --- Shell command parsing --- + +const RECURSIVE_SEARCHERS = new Set(["rg", "ag", "ack", "fd", "fdfind"]); +const GREP_BINARIES = new Set(["grep", "egrep", "fgrep", "ggrep"]); +const FIND_NAME_PREDICATES = new Set([ + "-name", + "-iname", + "-path", + "-ipath", + "-regex", + "-iregex", + "-wholename", + "-iwholename", +]); + +function tokenize(segment: string): string[] { + return (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((t) => + t.replace(/^["']|["']$/g, ""), + ); +} + +function commandSegments(command: string): string[] { + // Preserve quoted regex alternation, semicolons and spaces. + return command.match(/(?:"[^"]*"|'[^']*'|[^;|&\n])+/g) ?? []; +} + +/** + * Strip leading env assignments / wrappers (`FOO=1`, `sudo`, `command`, + * `env`, `time`, `nice`) so the actual binary is at index 0. + */ +function stripPrefixes(tokens: string[]): string[] { + let i = 0; + while (i < tokens.length) { + const t = tokens[i] ?? ""; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { + i++; + continue; + } + if ( + t === "sudo" || + t === "command" || + t === "env" || + t === "time" || + t === "nice" + ) { + i++; + // skip their own short options (e.g. `sudo -u root`, `env -i`) + while (i < tokens.length && (tokens[i] ?? "").startsWith("-")) i++; + continue; + } + break; + } + return tokens.slice(i); +} + +function basename(path: string): string { + const idx = path.lastIndexOf("/"); + return idx === -1 ? path : path.slice(idx + 1); +} + +function grepIsRecursive(args: string[]): boolean { + for (const a of args) { + if (a === "--") break; + if (a === "--recursive" || a === "--dereference-recursive") return true; + // Combined short flags: `-r`, `-rn`, `-Rin`, `-nri` … (not `--` long opts) + if (/^-[A-Za-z]+$/.test(a) && /[rR]/.test(a)) return true; + } + return false; +} + +function findHasNamePredicate(args: string[]): boolean { + return args.some((a) => FIND_NAME_PREDICATES.has(a)); +} + +/** + * Returns the leading search command of the first shell segment that performs + * a recursive code search, or null when the command is not a search. + * Exported for tests. + */ +export function detectNativeSearchCommand(command: string): string | null { + const segments = commandSegments(command); + for (const segment of segments) { + const tokens = stripPrefixes(tokenize(segment.trim())); + if (tokens.length === 0) continue; + const bin = basename(tokens[0] ?? ""); + const args = tokens.slice(1); + + if (RECURSIVE_SEARCHERS.has(bin)) return bin; + if (GREP_BINARIES.has(bin) && grepIsRecursive(args)) return bin; + if (bin === "find" && findHasNamePredicate(args)) return bin; + if (bin === "git" && args[0] === "grep") return "git grep"; + } + return null; +} + +// Options with values must not be mistaken for patterns or search roots. +const VALUE_OPTIONS = new Set([ + "-e", + "-f", + "-g", + "-t", + "-T", + "-A", + "-B", + "-C", + "-m", + "-j", + "-E", + "--regexp", + "--file", + "--glob", + "--iglob", + "--type", + "--type-not", + "--after-context", + "--before-context", + "--context", + "--max-count", + "--max-depth", + "--maxdepth", + "--max-filesize", + "--encoding", + "--threads", + "--color", + "--colors", + "--sort", + "--sortr", + "--ignore-file", + "--include", + "--exclude", + "--exclude-dir", + "--extension", + "-d", + "--search-path", + "--base-directory", +]); +const FD_VALUE_OPTIONS = new Set([ + "-e", + "--extension", + "-t", + "--type", + "-E", + "--exclude", + "-d", + "--max-depth", + "--color", + "-j", + "--threads", +]); +const FLAG_OPTIONS = new Set([ + "--hidden", + "--no-ignore", + "--no-ignore-vcs", + "--no-ignore-parent", + "--no-ignore-global", + "--files", + "--files-with-matches", + "--files-without-match", + "--line-number", + "--ignore-case", + "--smart-case", + "--fixed-strings", + "--recursive", + "--dereference-recursive", + "--heading", + "--no-heading", + "--count", + "--only-matching", + "--follow", + "--null", +]); + +function searchRoots(bin: string, args: string[]): string[] | null { + if (bin === "git") return null; // revisions/pathspec magic need the explicit fallback + if (bin === "find") { + if ( + args.some((arg) => ["-exec", "-execdir", "-ok", "-okdir"].includes(arg)) + ) + return null; + const roots: string[] = []; + for (const arg of args) { + if (arg.startsWith("-") || arg === "(") break; + roots.push(arg); + } + return roots; + } + const positional: string[] = []; + let hasPattern = false; + let filesOnly = false; + let options = true; + const fd = bin === "fd" || bin === "fdfind"; + const grep = GREP_BINARIES.has(bin); + const valueOptions = fd ? FD_VALUE_OPTIONS : VALUE_OPTIONS; + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ""; + if (options && arg === "--") { + options = false; + continue; + } + if (options && arg.startsWith("-")) { + if (bin === "ag" || bin === "ack") return null; + const option = arg.split("=", 1)[0] ?? arg; + if (option === "--search-path" || option === "--base-directory") + return null; + if (option === "--files" && bin === "rg") filesOnly = true; + if (grep && /^-[EF]+$/.test(arg)) continue; + if (fd && /^-[fFHILlis0]+$/.test(arg)) continue; + if (valueOptions.has(option)) { + if (["-e", "-f", "--regexp", "--file"].includes(option) && !fd) + hasPattern = true; + if (!arg.includes("=")) i++; + } else if ( + FLAG_OPTIONS.has(option) || + (!fd && /^-[nivwoxlLcrRshuUqFa0]+$/.test(arg)) + ) { + // Flags with no following value. + } else if (!fd && /^-[efgABCmjtT].+/.test(arg)) { + if (/^-[ef]/.test(arg)) hasPattern = true; + } else { + return null; // Unknown options may consume the apparent path. + } + } else { + positional.push(arg); + } + } + if (!hasPattern && !filesOnly) positional.shift(); + return positional; +} + +function shellSearchRoots( + command: string, + projectDir: string, +): string[] | null { + // Do not guess expansions, redirections, subshells or changing directories. + if (/[$`<>\\()]/.test(command)) return null; + const roots: string[] = []; + for (const segment of commandSegments(command)) { + const tokens = stripPrefixes(tokenize(segment.trim())); + const bin = basename(tokens[0] ?? ""); + if (["cd", "pushd", "popd"].includes(bin)) return null; + if (!detectNativeSearchCommand(segment)) continue; + const paths = searchRoots(bin, tokens.slice(1)); + if (!paths?.length) return null; + for (const path of paths) { + const literal = searchPathRoot(path); + if (!literal) return null; + roots.push(resolve(projectDir, literal)); + } + } + return roots; +} + +// --- Deny reasons --- + +function providerLabel(provider: CodeIntelligenceProvider): string { + return provider === "gortex" ? "Gortex" : "Serena"; +} + +function replacementFor( + provider: CodeIntelligenceProvider, + kind: "grep" | "glob" | "shell", +): string { + if (provider === "gortex") { + return "the Gortex MCP search/navigation tools"; + } + switch (kind) { + case "grep": + return "`mcp__serena__search_for_pattern` (or `find_symbol` / `get_symbols_overview` for symbols)"; + case "glob": + return "`mcp__serena__find_file`"; + case "shell": + return "`mcp__serena__search_for_pattern` for content and `mcp__serena__find_file` for paths"; + } +} + +function denyReason( + provider: CodeIntelligenceProvider, + kind: "grep" | "glob" | "shell", + detail: string, +): string { + const label = providerLabel(provider); + return ( + `[oma code-intelligence-guard] Blocked ${detail}: ${label} is the configured code-intelligence provider ` + + `(providers.code_intelligence in .agents/oma-config.yaml). Use ${replacementFor(provider, kind)} instead; ` + + `load the deferred ${label} tools first if needed. ` + + `Native search is only for paths outside this project or ignored paths. ` + + `Searches scoped entirely to confirmed provider exclusions or external paths are already allowed; ` + + `narrow the path if this search was meant to be one of those. ` + + `Do not bypass this guard to search project source. ` + + `Set providers.code_intelligence_guard: off to disable this guard.` + ); +} + +// ── Pure handler (canonical ABI) ───────────────────────────── + +/** + * Pure decision function — denies native code search when a code-intelligence + * provider is configured. Returns a `block` HandlerResult, else `null` + * (fail-open: no provider, guard off, non-search tool/command, bypass token). + */ +export async function run( + input: HookInput, + ctx: HandlerCtx, +): Promise { + if (input.kind !== "pre_tool") return null; + + const { toolName, toolInput } = input; + // Config and the project-scope check use the resolved project root; the + // tool's relative paths are relative to the session's working directory. + const projectDir = ctx.cwd || input.cwd; + const sessionCwd = input.cwd || projectDir; + + const isGrep = GREP_TOOLS.has(toolName); + const isGlob = GLOB_TOOLS.has(toolName); + const isShell = SHELL_TOOLS.has(toolName); + if (!isGrep && !isGlob && !isShell) return null; + + let searchBin: string | null = null; + if (isShell) { + const command = toolInput.command as string | undefined; + if (!command) return null; + if (command.includes(BYPASS_TOKEN)) return null; + searchBin = detectNativeSearchCommand(command); + if (!searchBin) return null; + } + + // Config reads happen after the cheap tool/command checks so the common + // (non-search) path never touches the filesystem. + const provider = detectCodeIntelligenceProvider(projectDir, ctx.config); + if (!provider) return null; + if (detectCodeIntelligenceGuardMode(projectDir, ctx.config) === "off") { + return null; + } + + let roots: string[] | null = null; + if (isShell) { + roots = shellSearchRoots(toolInput.command as string, sessionCwd); + } else if (isGlob && typeof toolInput.pattern === "string") { + const base = + typeof toolInput.path === "string" ? toolInput.path : sessionCwd; + const target = isAbsolute(toolInput.pattern) + ? toolInput.pattern + : `${resolve(sessionCwd, base)}/${toolInput.pattern}`; + const root = searchPathRoot(target); + if (root) roots = [root]; + } else if (typeof toolInput.path === "string") { + roots = [resolve(sessionCwd, toolInput.path)]; + } + if (roots && isExcludedSearchScope(provider, projectDir, roots)) return null; + + if (isGrep) { + return { + type: "block", + reason: denyReason(provider, "grep", `native \`${toolName}\``), + }; + } + if (isGlob) { + return { + type: "block", + reason: denyReason(provider, "glob", `native \`${toolName}\``), + }; + } + return { + type: "block", + reason: denyReason(provider, "shell", `shell search \`${searchBin}\``), + }; +} + +// ── Standalone entry (pi subprocess / direct bun invocation) ── + +interface PreToolUseInput { + tool_name: string; + tool_input: { + command?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +function main() { + const inputFile = process.env.OMA_HOOK_INPUT_FILE; + const raw = inputFile + ? readFileSync(inputFile, "utf-8") + : readFileSync(0, "utf-8"); + if (!raw.trim()) process.exit(0); + + const parsed: PreToolUseInput = JSON.parse(raw); + // Standalone path is vendor-agnostic here; claude covers the common dialect. + const vendor: Vendor = "claude"; + const projectDir = getProjectDir(vendor, parsed); + + const hookInput: HookInput = { + kind: "pre_tool", + toolName: parsed.tool_name, + toolInput: { ...(parsed.tool_input ?? {}) }, + cwd: projectDir, + }; + + run(hookInput, { vendor, cwd: projectDir }) + .then((result) => { + if (result && result.type === "block") { + console.log(makePreToolDenyOutput(vendor, result.reason)); + } + process.exit(0); + }) + .catch(() => process.exit(0)); +} + +if (import.meta.main) { + main(); +} diff --git a/.agents/hooks/core/code-intelligence-primer.ts b/.agents/hooks/core/code-intelligence-primer.ts new file mode 100644 index 0000000..c22980d --- /dev/null +++ b/.agents/hooks/core/code-intelligence-primer.ts @@ -0,0 +1,338 @@ +#!/usr/bin/env bun +/** + * oh-my-agent — Code Intelligence Primer Hook (prompt kind) + * + * Works with: Claude Code, Codex CLI, Cursor, Qwen Code, + * Antigravity, Grok, Kiro. + * + * Injects a short, vendor-neutral reminder ONCE per session so the selected + * code-intelligence provider (Serena or Gortex) tools are loaded and preferred. + * + * Gating: + * - Only fires when a code-intelligence provider is configured: + * - "gortex" via providers.code_intelligence in oma-config.yaml + * - "serena" via providers.code_intelligence or .serena/project.yml + * - Only fires once per session (state file under .agents/state/). + * + * Runs on the vendor's prompt event (UserPromptSubmit / BeforeAgent / + * PreInvocation / beforeSubmitPrompt / userPromptSubmit), after skill-injector. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { agyConversationId, readAgyPrompt } from "./agy-input.ts"; +import { makePromptOutput } from "./hook-output.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; +import type { + HandlerCtx, + HandlerResult, + HookConfig, + HookInput, +} from "./types.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; + +const SESSION_TTL_MS = 60 * 60 * 1000; + +export type CodeIntelligenceProvider = "serena" | "gortex"; + +// ── Provider Detection ──────────────────────────────────────── + +/** + * Read a scalar `providers.` value from oma-config yaml content without a + * yaml dependency (core handlers must stay standalone). Returns the lowercased + * value with trailing comments stripped, or null when absent. + */ +export function readProvidersValueFromYaml( + content: string, + key: string, +): string | null { + const lines = content.split(/\r?\n/); + const start = lines.findIndex((l) => /^providers:\s*(#.*)?$/.test(l)); + if (start === -1) return null; + const keyRe = new RegExp(`^\\s+${key}:\\s*(\\S.*)$`); + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (/^\s*(#|$)/.test(line)) continue; + if (!/^\s/.test(line)) break; + const match = line.match(keyRe)?.[1]; + if (match) { + return match + .replace(/#.*$/, "") + .trim() + .replace(/^["']|["']$/g, "") + .toLowerCase(); + } + } + return null; +} + +function readCodeIntelligenceFromYaml( + content: string, +): CodeIntelligenceProvider | null { + const val = readProvidersValueFromYaml(content, "code_intelligence"); + if (val === "gortex" || val === "serena") return val; + return null; +} + +/** + * `providers.` from the config `oma hook run` loaded, normalized like + * the YAML reader (lowercased; booleans as "true" / "false"). + */ +function readProvidersValueFromConfig( + config: HookConfig, + key: string, +): string | null { + const providers = config.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return null; + } + const value = (providers as Record)[key]; + if (typeof value === "boolean") return String(value); + return typeof value === "string" ? value.trim().toLowerCase() : null; +} + +/** + * Resolves the selected code-intelligence provider. + * Uses `config` (CUE / local overlay aware, from `oma hook run`) when given, + * otherwise oma-config.local.yaml and oma-config.yaml; falls back to + * detecting .serena/project.yml. + */ +export function detectCodeIntelligenceProvider( + projectDir: string, + config?: HookConfig, +): CodeIntelligenceProvider | null { + if (config) { + const val = readProvidersValueFromConfig(config, "code_intelligence"); + if (val === "gortex" || val === "serena") return val; + } else { + for (const rel of [ + join(".agents", "oma-config.local.yaml"), + join(".agents", "oma-config.yaml"), + ]) { + const p = join(projectDir, rel); + if (existsSync(p)) { + try { + const val = readCodeIntelligenceFromYaml(readFileSync(p, "utf-8")); + if (val) return val; + } catch { + // fall open + } + } + } + } + if (existsSync(join(projectDir, ".serena", "project.yml"))) { + return "serena"; + } + return null; +} + +/** + * Backward-compatible helper: true when Serena is the active provider. + */ +export function isSerenaProject(projectDir: string): boolean { + return detectCodeIntelligenceProvider(projectDir) === "serena"; +} + +export type CodeIntelligenceGuardMode = "block" | "off"; + +/** + * Resolves `providers.code_intelligence_guard` (oma-config.local.yaml wins + * over oma-config.yaml). `block` (default) makes code-intelligence-guard deny + * native search tool calls while a provider is configured; `off` disables the + * guard and leaves the primer advisory-only. + */ +export function detectCodeIntelligenceGuardMode( + projectDir: string, + config?: HookConfig, +): CodeIntelligenceGuardMode { + if (config) { + const val = readProvidersValueFromConfig(config, "code_intelligence_guard"); + return val === "off" || val === "false" || val === "warn" ? "off" : "block"; + } + for (const rel of [ + join(".agents", "oma-config.local.yaml"), + join(".agents", "oma-config.yaml"), + ]) { + const p = join(projectDir, rel); + if (!existsSync(p)) continue; + try { + const val = readProvidersValueFromYaml( + readFileSync(p, "utf-8"), + "code_intelligence_guard", + ); + if (val === "off" || val === "false" || val === "warn") return "off"; + if (val === "block" || val === "true") return "block"; + } catch { + // fall open to the default + } + } + return "block"; +} + +// ── Session-once State ──────────────────────────────────────── + +interface PrimerState { + sessions: Record; +} + +function getStatePath(projectDir: string): string { + const newPath = join( + projectDir, + ".agents", + "state", + "code-intelligence-primer.json", + ); + if (existsSync(newPath)) return newPath; + const legacyPath = join(projectDir, ".agents", "state", "serena-primer.json"); + if (existsSync(legacyPath)) return legacyPath; + return newPath; +} + +function readState(projectDir: string): PrimerState { + const p = getStatePath(projectDir); + if (!existsSync(p)) return { sessions: {} }; + try { + const parsed = JSON.parse(readFileSync(p, "utf-8")); + if (parsed && typeof parsed === "object" && parsed.sessions) { + return parsed as PrimerState; + } + } catch { + // corrupted — reset + } + return { sessions: {} }; +} + +function writeState(projectDir: string, state: PrimerState): void { + const p = getStatePath(projectDir); + try { + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(state, null, 2)); + } catch { + // failing open is acceptable — worst case the primer injects again + } +} + +/** + * Returns true and records the session when this is the first prompt of the + * session (within TTL); returns false on subsequent prompts. Expired sessions + * are pruned. Pure given `now` for testability. + */ +export function claimSession( + projectDir: string, + sessionId: string, + now: number = Date.now(), +): boolean { + const state = readState(projectDir); + + for (const [id, ts] of Object.entries(state.sessions)) { + if (now - ts > SESSION_TTL_MS) delete state.sessions[id]; + } + + const last = state.sessions[sessionId]; + if (last !== undefined && now - last <= SESSION_TTL_MS) { + return false; + } + + state.sessions[sessionId] = now; + writeState(projectDir, state); + return true; +} + +// ── Primer Content ──────────────────────────────────────────── + +/** + * Vendor-neutral code intelligence priming context. Kept short — it is + * injected once per session as advisory guidance, not a per-turn reminder. + */ +export function primerContext( + provider: CodeIntelligenceProvider = "serena", +): string { + if (provider === "gortex") { + return [ + "[OMA GORTEX PRIMER]", + "For code work, use Gortex MCP tools for code search, navigation, impact, contracts and edits.", + "A PreToolUse hook guards native Grep, Glob and recursive shell search. Searches confined to confirmed provider exclusions or paths outside this project are allowed.", + "Load deferred tools before use. Native search is only for paths outside this project or ignored paths. Do not use it to search project source. If Gortex is unavailable or times out, do not retry it this session.", + ].join("\n"); + } + return [ + "[OMA SERENA PRIMER]", + "For code work, load deferred Serena tools if needed and read `initial_instructions` once unless already provided.", + "Use `find_file` instead of Glob, `search_for_pattern` instead of Grep / recursive shell search, and `find_symbol` / `get_symbols_overview` for symbols. Native searches confined to confirmed provider exclusions or paths outside this project are allowed by the PreToolUse guard.", + "Omit `max_answer_chars`; narrow the query if results exceed the limit.", + "If Serena is unavailable or times out, do not retry the MCP call this session. Native search is only for paths outside this project or ignored paths. Do not use it to search project source.", + ].join("\n"); +} + +// ── Pure handler (canonical ABI) ───────────────────────────── + +/** + * Pure decision function — injects the code intelligence primer on the first + * prompt of an activated project's session, else returns null. + * `ctx.cwd` must be the resolved OMA project root. + */ +export async function run( + input: HookInput, + ctx: HandlerCtx, +): Promise { + if (input.kind !== "prompt") return null; + + const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; + + const provider = detectCodeIntelligenceProvider(projectDir, ctx.config); + if (!provider) 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(provider) }; +} + +// ── Standalone entry (pi subprocess / direct bun invocation) ── + +function getSessionId(input: Record): string { + return ( + (input.sessionId as string) || + (input.session_id as string) || + agyConversationId(input) || + "unknown" + ); +} + +export async function runStandAlone() { + const raw = readFileSync(0, "utf-8"); + let input: Record; + try { + input = JSON.parse(raw); + } catch { + process.exit(0); + } + + const vendor = detectVendorFromInput(input, "prompt", import.meta.filename); + const projectDir = getProjectDir(vendor, input); + const sessionId = getSessionId(input); + let prompt = normalizePromptInput(input.prompt); + + // agy's PreInvocation stdin carries no `prompt`; recover it and only act on + // the first invocation of a turn. + if (vendor === "antigravity" && !prompt) { + const invocationNum = input.invocationNum; + if (typeof invocationNum === "number" && invocationNum > 1) process.exit(0); + prompt = readAgyPrompt(input.transcriptPath); + } + + const hookInput: HookInput = { kind: "prompt", prompt, cwd: projectDir }; + const ctx: HandlerCtx = { vendor, cwd: projectDir, sid: sessionId }; + + const result = await run(hookInput, ctx); + if (result && result.type === "context") { + process.stdout.write(makePromptOutput(vendor, result.additionalContext)); + } + process.exit(0); +} + +if (import.meta.main) { + runStandAlone().catch(() => process.exit(0)); +} diff --git a/.agents/hooks/core/code-intelligence-scope.ts b/.agents/hooks/core/code-intelligence-scope.ts new file mode 100644 index 0000000..9ab964f --- /dev/null +++ b/.agents/hooks/core/code-intelligence-scope.ts @@ -0,0 +1,307 @@ +// Provider exclusions, not package directory names, define native-search scope. +// Keep this module dependency-free for standalone hook installations. Unknown +// configuration syntax never grants an exemption; the explicit fallback remains. +import { execFileSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { + dirname, + isAbsolute, + join, + matchesGlob, + relative, + resolve, + sep, +} from "node:path"; +import type { CodeIntelligenceProvider } from "./code-intelligence-primer.ts"; + +function read(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** Read simple YAML string lists; aliases, tags and complex YAML stay unknown. */ +function stringList(yaml: string, key: string): string[] | null { + const lines = yaml.split(/\r?\n/); + const start = lines.findIndex((line) => line.startsWith(`${key}:`)); + if (start < 0) return []; + const inline = lines[start] + ?.slice(key.length + 1) + .replace(/\s+#.*$/, "") + .trim(); + if (inline === "[]" || inline === "null") return []; + let values: string[]; + if (inline) { + // JSON lists are also YAML. Other flow styles use the explicit fallback. + try { + const parsed: unknown = JSON.parse(inline); + return Array.isArray(parsed) && parsed.every((p) => typeof p === "string") + ? parsed + : null; + } catch { + return null; + } + } else { + values = []; + for (const line of lines.slice(start + 1)) { + if (/^\s*(#|$)/.test(line)) continue; + if (/^[^\s-]/.test(line)) break; + const match = /^\s*-\s+(.+?)\s*$/.exec(line); + if (!match) return null; + values.push(match[1] ?? ""); + } + } + const result: string[] = []; + for (const value of values) { + const clean = value.replace(/\s+#.*$/, "").trim(); + if (clean.startsWith('"')) { + try { + const parsed: unknown = JSON.parse(clean); + if (typeof parsed !== "string") return null; + result.push(parsed); + } catch { + return null; + } + } else if (/^'[^']*'$/.test(clean)) { + result.push(clean.slice(1, -1)); + } else if (/^[^!&*[{>|'"#][^\s]*$/.test(clean)) { + result.push(clean); + } else { + return null; + } + } + return result; +} + +/** Literal directory prefix of a search glob; leading wildcards give no scope. */ +export function searchPathRoot(path: string): string | null { + path = path.split(sep).join("/"); + if (!path || /[$`\n\r\\{}()!]/.test(path)) return null; + const parts = path.split("/"); + const wildcard = parts.findIndex((part) => /[*?[]/.test(part)); + if (wildcard < 0) return path; + if (parts.slice(wildcard).includes("..")) return null; + return parts.slice(0, wildcard).join("/") || null; +} + +function excludedByPatterns( + path: string, + patterns: string[], + directory = true, +): boolean { + // A re-inclusion can make a subtree only partly excluded. Do not broaden the + // exemption when proving full containment would require a directory walk. + if (patterns.some((p) => p.startsWith("!"))) return false; + const ancestors = path + .split("/") + .map((_, i, parts) => parts.slice(0, i + 1).join("/")); + return patterns.some((raw) => { + // Restrict to the shared gitignore/glob subset; extglobs and escapes differ. + if (!raw || /[\\{}()[\]!#\s]/.test(raw)) return false; + const rooted = raw.startsWith("/"); + const pattern = raw.replace(/^\//, "").replace(/\/(?:\*\*)?$/, ""); + if (!pattern) return false; + const glob = rooted || pattern.includes("/") ? pattern : `**/${pattern}`; + return ancestors.some( + (ancestor, index) => + (!raw.endsWith("/") || index < ancestors.length - 1 || directory) && + matchesGlob(ancestor, glob), + ); + }); +} + +function hasReincludedPaths( + projectDir: string, + paths: string[], + names: string[], +): boolean { + const root = resolve(projectDir); + for (const path of paths) { + const target = resolve(root, path); + let current = target; + while (current === root || current.startsWith(`${root}${sep}`)) { + for (const name of names) { + for (const line of read(join(current, name)).split(/\r?\n/)) { + if (!line.startsWith("!")) continue; + const pattern = line.slice(1).trim().replace(/^\//, ""); + const scope = relative(current, target).split(sep).join("/"); + const anchor = searchPathRoot(pattern); + if ( + !scope || + !anchor || + !pattern.includes("/") || + anchor.startsWith(`${scope}/`) || + anchor === scope || + excludedByPatterns(scope, [pattern]) + ) + return true; + } + } + if (current === root) break; + current = dirname(current); + } + } + return false; +} + +function serenaExcluded(projectDir: string, paths: string[]): boolean { + const config = read(join(projectDir, ".serena", "project.yml")); + if (!config) return false; + const global = read( + join( + process.env.SERENA_HOME || join(homedir(), ".serena"), + "serena_config.yml", + ), + ); + const globalPatterns = stringList(global, "ignored_paths"); + const localPatterns = stringList(config, "ignored_paths"); + if (!globalPatterns || !localPatterns) return false; + if (/^<<\s*:/m.test(config) || /^<<\s*:/m.test(global)) return false; + const patterns = [...globalPatterns, ...localPatterns]; + if (patterns.some((p) => p.startsWith("!"))) return false; + const gitignoreSetting = /^ignore_all_files_in_gitignore:[ \t]*([^#\r\n]*)/m + .exec(config)?.[1] + ?.trim() + .toLowerCase(); + if ( + gitignoreSetting !== undefined && + gitignoreSetting !== "true" && + gitignoreSetting !== "false" + ) + return false; + const respectsGitignore = gitignoreSetting !== "false"; + if ( + respectsGitignore && + hasReincludedPaths(projectDir, paths, [".gitignore"]) + ) + return false; + const remaining = paths.filter((path) => { + const directory = isDirectory(resolve(projectDir, path)); + // Virtualenv tools often put `*` in the environment's own .gitignore + // instead of adding the environment name to the parent repository. + const ignoresContents = + respectsGitignore && + directory && + read(join(projectDir, path, ".gitignore")) + .split(/\r?\n/) + .some((line) => ["*", "**", "/*", "/**"].includes(line.trim())); + return !ignoresContents && !excludedByPatterns(path, patterns, directory); + }); + if (remaining.length === 0) return true; + if (!respectsGitignore) return false; + try { + const output = execFileSync( + "git", + ["check-ignore", "--no-index", "--verbose", "-z", "--stdin"], + { + cwd: projectDir, + input: remaining.map((path) => `${path}\0`).join(""), + encoding: "utf8", + timeout: 500, + stdio: ["pipe", "pipe", "ignore"], + }, + ); + const fields = output.split("\0"); + const ignored = new Set(); + for (let i = 0; i + 3 < fields.length; i += 4) { + // Serena reads .gitignore files, not Git's global/info exclude files. + const source = fields[i] ?? ""; + if ( + (source === ".gitignore" || source.endsWith("/.gitignore")) && + !fields[i + 2]?.startsWith("!") + ) { + ignored.add((fields[i + 3] ?? "").replace(/\/$/, "")); + } + } + return remaining.every((path) => ignored.has(path)); + } catch { + return false; + } +} + +function gortexExcluded(projectDir: string, paths: string[]): boolean { + // Use the provider's own layered list rather than copying its builtins or + // guessing where its global config lives. The CLI read is bounded and read-only. + // Current list output omits `include`; conservatively reject that override. + let dir = resolve(projectDir); + while (true) { + if (/^include\s*:/m.test(read(join(dir, ".gortex.yaml")))) return false; + if (dirname(dir) === dir) break; + dir = dirname(dir); + } + try { + const output = execFileSync("gortex", ["config", "exclude", "list"], { + cwd: projectDir, + encoding: "utf8", + timeout: 500, + stdio: ["ignore", "pipe", "ignore"], + }); + const patterns: string[] = []; + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue; + const match = + /^\[(?:builtin|global|repo:[^\]]+|workspace(?: \(legacy (?:index|watch)\.exclude\))?)\s*\]\s+(.+?)\s*$/.exec( + line, + ); + if (!match?.[1]) return false; + patterns.push(match[1]); + } + // Per-directory overrides can re-include descendants of an excluded root. + // Without a provider path-level query those cases need the explicit fallback. + if ( + hasReincludedPaths(projectDir, paths, [ + ".gortexignore", + ".ignore", + ".rgignore", + ".gitignore", + ]) + ) + return false; + return paths.every((path) => + excludedByPatterns( + path, + patterns, + isDirectory(resolve(projectDir, path)), + ), + ); + } catch { + return false; + } +} + +export function isExcludedSearchScope( + provider: CodeIntelligenceProvider, + projectDir: string, + roots: string[], +): boolean { + if (roots.length === 0) return false; + const paths: string[] = []; + for (const root of roots) { + const literal = searchPathRoot(root); + if (!literal) return false; + const path = relative(projectDir, resolve(projectDir, literal)) + .split(sep) + .join("/"); + if (!path) return false; + // Explicit external paths, such as uv's cache, are outside this project. + if (path === ".." || path.startsWith(`../`) || isAbsolute(path)) continue; + paths.push(path); + } + return ( + paths.length === 0 || + (provider === "serena" + ? serenaExcluded(projectDir, paths) + : gortexExcluded(projectDir, paths)) + ); +} 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/event-contract.ts b/.agents/hooks/core/event-contract.ts new file mode 100644 index 0000000..005d278 --- /dev/null +++ b/.agents/hooks/core/event-contract.ts @@ -0,0 +1,61 @@ +/** Payload contracts shared by hook writers, CLI verification, and diagnostics. */ +export function isEventRecord( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isNonblankEventText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function validateEventPayload(kind: string, payload: unknown): string[] { + const errors: string[] = []; + const fields = + kind === "decision.made" + ? ["subject", "decision", "rationale"] + : kind === "blocker.raised" + ? ["summary"] + : []; + const record = isEventRecord(payload) ? payload : undefined; + if (payload !== undefined && !record) + errors.push("payload must be an object"); + for (const field of fields) { + if (!isNonblankEventText(record?.[field])) { + errors.push(`payload.${field} must be a nonblank string`); + } + } + if ( + kind === "session.ended" && + record?.status !== "completed" && + record?.status !== "failed" + ) { + errors.push("payload.status must be completed or failed"); + } + if ( + record?.instanceId !== undefined && + !isNonblankEventText(record.instanceId) + ) { + errors.push("payload.instanceId must be a nonblank string"); + } + return errors; +} + +export function validateEventEnvelope(value: unknown): string[] { + if (!isEventRecord(value)) return ["event must be an object"]; + const errors: string[] = []; + for (const field of ["eventId", "ts", "sid", "kind"]) { + if (!isNonblankEventText(value[field])) { + errors.push(`${field} must be a nonblank string`); + } + } + if (isNonblankEventText(value.ts) && !Number.isFinite(Date.parse(value.ts))) { + errors.push("ts must be a valid timestamp"); + } + if (!Number.isInteger(value.writerPid)) + errors.push("writerPid must be an integer"); + if (typeof value.kind === "string") { + errors.push(...validateEventPayload(value.kind, value.payload)); + } + return errors; +} diff --git a/.agents/hooks/core/evolution-notice.ts b/.agents/hooks/core/evolution-notice.ts new file mode 100644 index 0000000..028cf8c --- /dev/null +++ b/.agents/hooks/core/evolution-notice.ts @@ -0,0 +1,146 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +/** + * Session-start notice for the self-improvement loop. The lineage logs are + * append-only; this reads what was promoted since the last session that + * showed a notice, renders one line per change, and moves the marker. A + * change is announced exactly once, and nothing is announced when nothing + * moved. + */ + +const MARKER = ".agents/state/evolution-notice.json"; +const SKILL_ROOT = ".agents/results/skill-evolution"; + +interface SkillRecord { + schemaVersion: number; + ts: string; + action: "apply" | "rollback"; + skillId: string; + parentHash: string; + candidateHash: string; + evidence?: { + baselineLift?: number; + finalLift?: number; + finalTest?: { passed?: boolean }; + edits?: Array<{ op: string; anchor: string; after?: string }>; + gains?: { train?: [number, number] }; + }; +} + +interface ProcedureRecord { + schemaVersion: number; + ts: string; + action: "apply" | "rollback"; + target: string; + parentHash: string; + candidateHash: string; + evidence?: { meanDiff?: number; pairs?: number; skills?: string[] }; +} + +function readLines(path: string): T[] { + if (!existsSync(path)) return []; + const out: T[] = []; + for (const line of readFileSync(path, "utf-8").split("\n")) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as T & { schemaVersion?: number }; + if (parsed.schemaVersion === 1) out.push(parsed); + } catch { + // damaged line: skipped, the log stays append-only evidence + } + } + return out; +} + +function readMarker(projectDir: string): string { + try { + const parsed = JSON.parse( + readFileSync(join(projectDir, MARKER), "utf-8"), + ) as { lastSeen?: unknown }; + return typeof parsed.lastSeen === "string" ? parsed.lastSeen : ""; + } catch { + return ""; + } +} + +function writeMarker(projectDir: string, lastSeen: string): void { + const path = join(projectDir, MARKER); + mkdirSync(join(projectDir, ".agents", "state"), { recursive: true }); + writeFileSync(path, `${JSON.stringify({ lastSeen })}\n`, "utf-8"); +} + +function pct(value: number | undefined): string { + return value === undefined ? "?" : `${Math.round(value * 100)}%`; +} + +function describeSkill(record: SkillRecord): string { + if (record.action === "rollback") + return `${record.skillId} rolled back to ${record.parentHash.slice(0, 8)}`; + const edit = record.evidence?.edits?.[0]; + const what = edit + ? `${edit.op} "${edit.anchor.replace(/\s+/g, " ").slice(0, 48)}${edit.anchor.length > 48 ? "…" : ""}"${(record.evidence?.edits?.length ?? 0) > 1 ? ` +${(record.evidence?.edits?.length ?? 1) - 1}` : ""}` + : `${record.parentHash.slice(0, 8)} → ${record.candidateHash.slice(0, 8)}`; + const train = record.evidence?.gains?.train; + const gains = [ + train ? `train ${pct(train[0])}→${pct(train[1])}` : "", + `validation ${pct(record.evidence?.baselineLift)}→${pct(record.evidence?.finalLift)}`, + ] + .filter(Boolean) + .join(", "); + return `${record.skillId}: ${what} (${gains})`; +} + +function describeProcedure(record: ProcedureRecord): string { + const e = record.evidence; + const stats = e + ? ` (mean gain diff ${(e.meanDiff ?? 0) >= 0 ? "+" : ""}${(e.meanDiff ?? 0).toFixed(2)}, ${e.pairs ?? 0} pairs)` + : ""; + return `${record.target} procedure ${record.parentHash.slice(0, 8)} → ${record.candidateHash.slice(0, 8)}${stats}`; +} + +/** + * Lines to show once for changes since the last notice; `[]` when nothing + * changed. Moves the marker when `advance` is true (the default). + */ +export function evolutionNoticeLines( + projectDir: string, + options: { advance?: boolean; limit?: number } = {}, +): string[] { + const root = join(projectDir, SKILL_ROOT); + if (!existsSync(root)) return []; + const since = readMarker(projectDir); + const skillRecords: SkillRecord[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith("_")) continue; + skillRecords.push( + ...readLines(join(root, entry.name, "promotions.jsonl")), + ); + } + const procedureRecords = readLines( + join(root, "_procedure", "promotions.jsonl"), + ); + const fresh = [ + ...skillRecords + .filter((r) => r.ts > since) + .map((r) => ({ ts: r.ts, line: describeSkill(r) })), + ...procedureRecords + .filter((r) => r.ts > since) + .map((r) => ({ ts: r.ts, line: describeProcedure(r) })), + ].sort((a, b) => a.ts.localeCompare(b.ts)); + if (fresh.length === 0) return []; + const latest = fresh[fresh.length - 1]?.ts ?? since; + if (options.advance !== false) writeMarker(projectDir, latest); + const limit = options.limit ?? 5; + const shown = fresh + .slice(-limit) + .map((f) => `- ${f.ts.slice(0, 16)} ${f.line}`); + if (fresh.length > limit) shown.unshift(`- …${fresh.length - limit} earlier`); + return shown; +} diff --git a/.agents/hooks/core/fs-utils.ts b/.agents/hooks/core/fs-utils.ts index af3acb7..e5676d5 100644 --- a/.agents/hooks/core/fs-utils.ts +++ b/.agents/hooks/core/fs-utils.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; -import { dirname, join, sep } from "node:path"; +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; /** * Normalize a filesystem path to POSIX (forward-slash) form so output @@ -10,14 +11,14 @@ export function toPosixPath(p: string): string { return sep === "/" ? p : p.split(sep).join("/"); } +const MAX_DEPTH = 20; + /** * Walk up from startDir to find the git repository root. * This prevents CLAUDE_PROJECT_DIR pointing to a subdirectory * (e.g. packages/i18n during a build) from creating state files * in the wrong location. */ -const MAX_DEPTH = 20; - export function resolveGitRoot(startDir: string): string { let dir = startDir; for (let i = 0; i < MAX_DEPTH; i++) { @@ -28,3 +29,59 @@ export function resolveGitRoot(startDir: string): string { } return startDir; } + +/** + * Files under `/.agents/` that mark an OMA install. A bare `.agents/` + * holding only runtime output (a stray `state/` or `backup/` tree written from + * a sub-directory) is not an install and never becomes the project root. + */ +export const OMA_INSTALL_MARKERS = [ + "oma-config.yaml", + "oma-config.cue", + "oma-config.local.yaml", + "oma-config.local.cue", + join("skills", "_version.json"), +] as const; + +export function hasOmaInstall(dir: string): boolean { + const agentsDir = join(dir, ".agents"); + return OMA_INSTALL_MARKERS.some((marker) => + existsSync(join(agentsDir, marker)), + ); +} + +function homeDirectory(): string | null { + try { + return resolve(homedir()); + } catch { + return null; + } +} + +/** + * Resolve the OMA project root. Shared by the hooks (standalone and + * `oma hook run`) and the CLI so state and config always land in the same + * place for a given working directory. + * + * Walks up from `startDir` and returns the nearest directory whose `.agents/` + * holds an install marker, without crossing the enclosing git root; otherwise + * that git root; otherwise `startDir`. A sub-package with its own install + * (`apps/api/.agents/oma-config.yaml`) is its own root, while a nested + * directory without one resolves to the repository. Outside a repository the + * walk stops at the home directory, whose `.agents/` is the global install + * rather than a project. + */ +export function resolveProjectRoot(startDir: string): string { + const start = resolve(startDir); + const home = homeDirectory(); + let dir = start; + for (let i = 0; i < MAX_DEPTH; i++) { + const isGitRoot = existsSync(join(dir, ".git")); + if (dir === home && !isGitRoot) break; + if (isGitRoot || hasOmaInstall(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return start; +} diff --git a/.agents/hooks/core/hook-output.ts b/.agents/hooks/core/hook-output.ts index d9ab4a3..e506a33 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,44 +97,164 @@ 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, + }, + }); } } -export function makePreToolOutput( +/** + * Post-tool BLOCK dialect — used when a PostToolUse handler wants the tool + * result fed back to the model with a mandatory instruction (refactor-guard). + * The tool has already run, so this is feedback, not prevention. + * + * Claude Code's current spec (code.claude.com/docs/en/hooks, verified 2026-08) + * feeds `additionalContext` directly to the model and no longer documents + * `{decision:"block"}` for PostToolUse; older builds read the decision keys. + * Codex / Qwen / Command Code document BOTH `decision:"block"` + `reason` and + * `hookSpecificOutput.additionalContext` on PostToolUse, so emitting the + * combined shape covers every wired dialect. Vendors without a usable + * post-tool feedback channel (grok: passive stdout-ignored; kiro: output not + * processed; antigravity: audit-only `{}`) fall through to the closest + * best-effort shape — they are not wired for post_tool events. + */ +export function makePostToolBlockOutput( vendor: Vendor, - updatedInput: Record, + reason: string, ): 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). + case "claude": + case "codex": + case "commandcode": + case "qwen": + return JSON.stringify({ + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: reason, + }, + }); + case "cursor": + // Cursor's postToolUse feedback channel is `additional_context` only + // (no block). Include the snake_case field its dialect documents. return JSON.stringify({ + additional_context: reason, hookSpecificOutput: { - hookEventName: "BeforeTool", - tool_input: updatedInput, + hookEventName: "PostToolUse", + additionalContext: reason, }, }); + case "kimi": + case "kiro": + case "grok": + case "antigravity": + case "pi": + // Best-effort — no documented post-tool feedback channel (or disputed). + return makeBlockOutput(vendor, reason); + } +} + +/** + * Pre-tool DENY dialect — used when a PreToolUse handler blocks a tool call + * (scm-guard). Distinct from makeBlockOutput, whose dialects are Stop-shaped + * (e.g. cursor's followup_message re-submits a turn instead of denying). + * scm-guard is wired for claude/codex/qwen/kimi/kiro/grok/cursor (plus the + * opencode/pi bridges, which read the claude dialect from the standalone + * entry); the remaining cases are best-effort so the switch stays exhaustive. + */ +export function makePreToolDenyOutput(vendor: Vendor, reason: string): string { + switch (vendor) { + case "claude": + case "codex": + case "commandcode": + case "qwen": + // Claude-documented PreToolUse deny shape (Codex/Qwen follow the dialect). + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }); + case "kimi": + // Kimi honours both the Claude-style decision keys and the + // hookSpecificOutput deny form (same rationale as makeBlockOutput). + return JSON.stringify({ + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }); + case "kiro": + return JSON.stringify({ decision: "block", reason }); + case "grok": + // Grok PreToolUse output is a gate decision (see makePreToolOutput). + return JSON.stringify({ decision: "deny", reason }); + case "antigravity": + // agy PreToolUse output is a gate decision; deny analog of the allow gate. + return JSON.stringify({ decision: "deny", reason }); + case "cursor": + // Documented preToolUse output (cursor.com/docs/hooks): permission + // allow|deny, user_message shown in the client, agent_message sent to + // the agent. Cursor also accepts Claude's nested hookSpecificOutput + // form, but the native flat shape is authoritative. + return JSON.stringify({ + permission: "deny", + user_message: reason, + agent_message: reason, + }); + case "pi": + // pi's bridge accepts {block:true, reason} on tool_call interception. + return JSON.stringify({ block: true, reason }); + } +} + +export function makePreToolOutput( + vendor: Vendor, + updatedInput: Record, +): string { + switch (vendor) { case "cursor": return JSON.stringify({ updated_input: updatedInput, @@ -134,6 +266,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..9e835aa 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`; @@ -65,6 +39,8 @@ interface RateLimit { interface StatuslineStdin { cwd?: string; + /** Claude / Qwen: the session the statusline is drawn for. */ + session_id?: string; model?: { id?: string; display_name?: string }; context_window?: { context_window_size?: number; @@ -105,48 +81,42 @@ 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 { - return readFileSync(0, "utf-8"); - } catch { - return ""; +async function readStdin( + timeoutMs = STDIN_TIMEOUT_MS, +): Promise { + // Some vendors (notably agy) spawn the statusline without closing the stdin + // pipe; a synchronous readFileSync(0) then blocks until the vendor's + // statusline timeout SIGKILLs the process. Read asynchronously and give up + // after timeoutMs, letting main() fall back to a payload-less line. + const read = (async () => { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); } - })(); - maybeDumpDebugPayload(raw); - try { - return JSON.parse(raw); - } catch { - return {}; - } -} - -function readRaw(): unknown { - const raw = (() => { + const raw = Buffer.concat(chunks).toString("utf-8"); + maybeDumpDebugPayload(raw); try { - return readFileSync(0, "utf-8"); + return JSON.parse(raw) as StatuslineStdin; } catch { - return ""; + return {}; } })(); - maybeDumpDebugPayload(raw); + + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("stdin timeout")), timeoutMs); + }); + try { - return JSON.parse(raw); - } catch { - return {}; + return await Promise.race([read, timeout]); + } finally { + clearTimeout(timer); } } +/** Max time to wait for the vendor to deliver (and close) the stdin payload. */ +const STDIN_TIMEOUT_MS = 800; + /** * 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, @@ -168,28 +138,51 @@ function maybeDumpDebugPayload(raw: string): void { // ── Active Workflow Detection ───────────────────────────────── -function getActiveWorkflow(projectDir: string): ModeState | null { +const STALE_WORKFLOW_MS = 2 * 60 * 60 * 1000; + +/** + * The persistent workflow to show. With a session id (Claude/Qwen + * `session_id`, agy `conversation_id`) only that session's state file counts, + * so a concurrent session's workflow never appears here. Each file is judged + * on its own: one corrupt or half-written file cannot hide the others. + */ +export function getActiveWorkflow( + projectDir: string, + sessionId?: string, +): ModeState | null { const stateDir = join(projectDir, ".agents", "state"); if (!existsSync(stateDir)) return null; + let files: string[]; try { - for (const file of readdirSync(stateDir)) { - if (!file.endsWith(".json") || !file.includes("-state-")) continue; - const content = readFileSync(join(stateDir, file), "utf-8"); - const state: ModeState = JSON.parse(content); - - // Skip stale (>2h) - const elapsed = Date.now() - new Date(state.activatedAt).getTime(); - if (elapsed > 2 * 60 * 60 * 1000) continue; - - return state; - } + files = readdirSync(stateDir); } catch { - // ignore + return null; + } + const sessionSuffix = sessionId ? `-state-${sessionId}.json` : null; + for (const file of files) { + if (!file.endsWith(".json") || !file.includes("-state-")) continue; + if (sessionSuffix && !file.endsWith(sessionSuffix)) continue; + let state: ModeState; + try { + state = JSON.parse(readFileSync(join(stateDir, file), "utf-8")); + } catch { + continue; + } + if (!state || typeof state.workflow !== "string") continue; + const activatedMs = new Date(state.activatedAt).getTime(); + if (!Number.isFinite(activatedMs)) continue; + if (Date.now() - activatedMs > STALE_WORKFLOW_MS) continue; + return state; } return null; } +function statuslineSessionId(input: StatuslineStdin): string | undefined { + const id = input.session_id ?? input.conversation_id; + return typeof id === "string" && id.trim() ? id : undefined; +} + // ── Model Name Shortener ────────────────────────────────────── export function shortModel(model?: { @@ -201,11 +194,20 @@ export function shortModel(model?: { // Claude: "Claude Opus 4.6 (1M context)" → "Opus 4.6" const claude = name.match(/(Opus|Sonnet|Haiku)[\s.]*([\d.]*)/i); if (claude) return `${claude[1]}${claude[2] ? ` ${claude[2]}` : ""}`; - // Gemini / agy: "Gemini 3.5 Flash (High)" → "Gemini 3.5 Flash" + // Gemini / agy: "Gemini 3.6 Flash (High)" → "Gemini 3.6 Flash" const gemini = name.match( /(Gemini)\s+([\d.]+)\s+(Pro|Flash|Ultra|Nano|Thinking)/i, ); if (gemini) return `${gemini[1]} ${gemini[2]} ${gemini[3]}`; + // Gemini / agy slug: "antigravity/gemini-3.6-flash" → "Gemini 3.6 Flash" + const geminiSlug = name.match( + /gemini-([\d.]+)-(pro|flash|ultra|nano|thinking)/i, + ); + if (geminiSlug) { + const type = geminiSlug[2] ?? ""; + const capType = type.charAt(0).toUpperCase() + type.slice(1); + return `Gemini ${geminiSlug[1]} ${capType}`; + } return name.split("/").pop()?.slice(0, 20) || ""; } @@ -235,67 +237,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 { @@ -377,8 +318,8 @@ export function buildClaudeStatusline(input: StatuslineStdin): string { parts.push(dim("sandbox")); } - // 8. Active workflow - const workflow = getActiveWorkflow(projectDir); + // 8. Active workflow (this session's only, when the vendor says which) + const workflow = getActiveWorkflow(projectDir, statuslineSessionId(input)); if (workflow) { parts.push(yellow(`${workflow.workflow}:${workflow.reinforcementCount}`)); } @@ -396,23 +337,19 @@ 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; +async function main() { + let out: string; + try { + out = buildClaudeStatusline(await readStdin()); + } catch { + // stdin timed out — a payload-less line beats a killed statusline + out = buildClaudeStatusline({}); } - - process.stdout.write(buildClaudeStatusline(readStdin())); + // Exit explicitly: after a timeout the pending stdin read would otherwise + // keep the event loop (and the process) alive. + process.stdout.write(out, () => process.exit(0)); } -main(); +if (import.meta.main) { + void main(); +} diff --git a/.agents/hooks/core/inject-log.ts b/.agents/hooks/core/inject-log.ts index af0ebda..9111f35 100644 --- a/.agents/hooks/core/inject-log.ts +++ b/.agents/hooks/core/inject-log.ts @@ -1,6 +1,8 @@ #!/usr/bin/env bun import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { ensureSessionStorage } from "./session-storage.ts"; +import { withSessionWriteLock } from "./state-index-lock.ts"; import { sessionDir } from "./state-marker.ts"; import type { MemoryFact } from "./vendor-renderer.ts"; @@ -8,7 +10,8 @@ import type { MemoryFact } from "./vendor-renderer.ts"; * Per-boundary inject audit log (D52) with privacy guards (D57). * * Every boundary inject writes - * `.agents/state/sessions/{sid}/inject-log/{ISO-ts}.md` containing the rendered + * `~/.oma/u//sessions/{sid}/inject-log/{ISO-ts}.md` (or an existing + * legacy project session directory) containing the rendered * markdown, the recall query, and the facts returned — a forensic trail for * debugging "resume context looks wrong" issues. * @@ -134,14 +137,17 @@ export function writeInjectLog( entry: InjectLogEntry, ): string | null { try { - const dir = injectLogDir(projectDir, sid); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - const path = join(dir, injectLogFilename(entry.boundaryAt)); - writeFileSync(path, renderInjectLog(entry), { - encoding: "utf-8", - mode: 0o600, + return withSessionWriteLock(projectDir, sid, () => { + ensureSessionStorage(projectDir, sid); + const dir = injectLogDir(projectDir, sid); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const path = join(dir, injectLogFilename(entry.boundaryAt)); + writeFileSync(path, renderInjectLog(entry), { + encoding: "utf-8", + mode: 0o600, + }); + return path; }); - return path; } catch { return null; } diff --git a/.agents/hooks/core/keyword-detector.ts b/.agents/hooks/core/keyword-detector.ts index 35deadf..76b9c73 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,18 +21,12 @@ 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 { clearGrokContext } from "./grok-context.ts"; +import { agyConversationId, readAgyPrompt } from "./agy-input.ts"; +import { UNKNOWN_SESSION_ID, VENDORS } from "./constants.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 +// binary (bundled `oma hook run` path needs no file on disk), while a standalone // bun run resolves the sibling file next to this module (pi / direct run). import embeddedTriggers from "./triggers.json" with { type: "json" }; import type { @@ -40,8 +34,8 @@ import type { HandlerResult, HookInput, ModeState, - Vendor, } from "./types.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; // ── Unicode normalization ───────────────────────────────────── @@ -62,7 +56,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. * @@ -83,6 +77,8 @@ const CLI_INVOCATION_SIGNALS = [ const BRANDS_RE_SOURCE = CLI_INVOCATION_BRANDS.join("|"); const SIGNALS_RE_SOURCE = CLI_INVOCATION_SIGNALS.join("|"); +// Require a resource and action so conversational mentions of OMA still trigger. +const OMA_RESOURCE_ACTION = String.raw`oma\s+(?:schedule|memory|model|state|goal|ralph|auth|dashboard|hook|skill|slide|image|video|vault|search|serena)\s+(?:create|list|delete|run|sync|daemon|service|retry|maintain|init|setup|status|import|gc|upgrade|check|probe|propose|get|activate|archive|purge|repair|verify|emit|decisions|inject-log|summary|heal-check|set|terminal|web|audit|lint|eval|optimize|preview|export|asset|style|vendor|provider|api|rss|reaper)(?=\s|$)`; /** * Matches CLI invocations at the start of the prompt. @@ -102,12 +98,11 @@ const SIGNALS_RE_SOURCE = CLI_INVOCATION_SIGNALS.join("|"); * 2. Bare form: '\s+' where is one of the * 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'. + * Examples: 'oma agent spawn brainstorm', 'claude --help', + * '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}))`, + `^\\s*(?:${OMA_RESOURCE_ACTION}|\\/(?:${BRANDS_RE_SOURCE}):|(?:${BRANDS_RE_SOURCE})\\s+(?:${SIGNALS_RE_SOURCE}))`, "i", ); @@ -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,183 +272,145 @@ 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(); - if (byScriptPath) return byScriptPath; - - // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. - if (isAgyInput(input)) return "antigravity"; - - // Grok uses hookEventName (e.g. "user_prompt_submit") + GROK_* env vars - if (process.env.GROK_WORKSPACE_ROOT || hookEventName?.includes("prompt")) { - // Prefer explicit grok signal; fall through to other checks only if ambiguous - if (process.env.GROK_WORKSPACE_ROOT) return "grok"; - } - - if ( - process.env.KIRO_PROJECT_DIR || - event === "userPromptSubmit" || - hookEventName === "userPromptSubmit" - ) { - return "kiro"; - } - - 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 - if ("session_id" in input && !("sessionId" in input)) return "codex"; - } - // Qwen Code sets QWEN_PROJECT_DIR; Claude sets CLAUDE_PROJECT_DIR - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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 ); } // ── Config Loading ──────────────────────────────────────────── -interface TriggerConfig { - workflows: Record< - string, - { - persistent: boolean; - keywords: Record; - patterns?: Record; - } - >; +export interface TriggerWorkflowDef { + persistent: boolean; + keywords: Record; + patterns?: Record; + /** + * Persistent workflows only: the subset of `keywords` that counts as an + * EXPLICIT invocation (the workflow's own name, e.g. "ultrawork", "ralph"). + * Only an explicit match writes the persistent-mode state file the Stop + * hook enforces; any other keyword/pattern match just injects the workflow + * context as a suggestion. Every entry must also appear in `keywords`. + */ + explicit?: string[]; +} + +export interface TriggerConfig { + workflows: Record; informationalPatterns: Record; excludedWorkflows: string[]; - cjkScripts: string[]; extensionRouting?: Record; } -/** Load the triggers config from the embedded (bundler-inlined / sibling-resolved) JSON. */ -function loadConfig(): TriggerConfig { - return structuredClone(embeddedTriggers) as TriggerConfig; +/** + * The embedded (bundler-inlined / sibling-resolved) triggers config. Read + * only — handlers never mutate it, so it is shared instead of cloned per call. + */ +const TRIGGERS = embeddedTriggers as unknown as TriggerConfig; + +// ── Pattern Builder ─────────────────────────────────────────── + +export function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function detectLanguage(projectDir: string): string { - const prefsPath = join(projectDir, ".agents", "oma-config.yaml"); - if (!existsSync(prefsPath)) return "en"; - try { - const content = readFileSync(prefsPath, "utf-8"); - const match = content.match(/^language:\s*(\S+)/m); - return match?.[1] ?? "en"; - } catch { - return "en"; +/** + * Merge a language-keyed keyword/pattern bank into a single flat list: + * universal ("*") + English + EVERY other language's entries, deduped + * case-insensitively. Same rationale as RC4 (buildInformationalPatterns): + * users prompt in whichever language they think in — `language` in + * oma-config.yaml controls the RESPONSE language, not the prompt language — + * so gating by config language silently disabled e.g. every Korean trigger + * for `language: en` projects. A keyword written in language X can only + * match a prompt that contains X-script text (current banks are en/ko/ja/zh; + * if a Latin-script bank like es/fr is ever added, phrase distinctiveness is + * the gate instead), so merging all languages cannot fire on unrelated + * prompts. Shared by buildPatterns and buildRawPatterns — both keyword banks + * and pattern banks use this exact `Record` shape. + */ +export function collectLangEntries(bank: Record): string[] { + const ordered = [ + ...(bank["*"] ?? []), + ...(bank.en ?? []), + ...Object.entries(bank) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), + ]; + const seen = new Set(); + const out: string[] = []; + for (const entry of ordered) { + const key = entry.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); } + return out; } -// ── Pattern Builder ─────────────────────────────────────────── +/** + * 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 escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** + * Compile one literal keyword. The boundary choice depends only on the + * keyword itself, never on the configured response language: ASCII keywords + * get hyphen-rejecting word boundaries, keywords containing non-ASCII text + * (CJK, accented Latin, Cyrillic) match as substrings because those scripts + * attach particles/inflections directly to the word. Gating this on + * `language: ko|ja|zh` used to strip the boundaries from EVERY ASCII keyword + * in CJK-configured projects, so "network" fired `work` and "preview" fired + * `review`. Hangul/kana/Han are not `\w`, so a boundary-wrapped ASCII keyword + * still matches when a CJK particle follows it ("ralph로"). + */ +export function compileKeyword(kw: string): RegExp { + const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); + return /[^\p{ASCII}]/u.test(kw) + ? new RegExp(escaped, "i") + : new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); } -export function buildPatterns( +export function buildPatternEntries( keywords: Record, - lang: string, - cjkScripts: string[], -): RegExp[] { - const allKeywords = [ - ...(keywords["*"] ?? []), - ...(keywords.en ?? []), - ...(lang !== "en" ? (keywords[lang] ?? []) : []), - ]; +): KeywordPatternEntry[] { + return collectLangEntries(keywords).map((kw) => ({ + regex: compileKeyword(kw), + keyword: kw, + })); +} - 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"); - } - return new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); - }); +export function buildPatterns(keywords: Record): RegExp[] { + return buildPatternEntries(keywords).map((e) => e.regex); } /** - * Build raw regex patterns from a workflow's `patterns` field. - * Unlike buildPatterns, these strings are compiled directly without - * escaping or word-boundary wrapping — pattern authors are responsible - * for boundary handling. Invalid patterns are skipped silently. + * 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 function buildRawPatterns( +export interface RawPatternEntry { + regex: RegExp; + source: string; +} + +export function buildRawPatternEntries( patterns: Record | undefined, - lang: string, -): RegExp[] { +): RawPatternEntry[] { if (!patterns) return []; - const all = [ - ...(patterns["*"] ?? []), - ...(patterns.en ?? []), - ...(lang !== "en" ? (patterns[lang] ?? []) : []), - ]; - const compiled: RegExp[] = []; - for (const raw of all) { + const compiled: RawPatternEntry[] = []; + for (const raw of collectLangEntries(patterns)) { try { - compiled.push(new RegExp(raw, "iu")); + compiled.push({ regex: new RegExp(raw, "iu"), source: raw }); } catch { // Skip invalid regex — surfaces during config edit, not at runtime } @@ -457,6 +418,18 @@ export function buildRawPatterns( return compiled; } +/** + * Build raw regex patterns from a workflow's `patterns` field. + * Unlike buildPatterns, these strings are compiled directly without + * escaping or word-boundary wrapping — pattern authors are responsible + * for boundary handling. Invalid patterns are skipped silently. + */ +export function buildRawPatterns( + patterns: Record | undefined, +): RegExp[] { + return buildRawPatternEntries(patterns).map((e) => e.regex); +} + export function buildInformationalPatterns(config: TriggerConfig): RegExp[] { // RC4: suppression patterns are merged across ALL languages, never gated by // the configured language. Users prompt in whichever language they think in @@ -481,7 +454,14 @@ export function isInformationalContext( ): boolean { const windowStart = Math.max(0, matchIndex - 60); const window = prompt.slice(windowStart, matchIndex + 60); - return infoPatterns.some((p) => p.test(window)); + if (infoPatterns.some((p) => p.test(window))) return true; + if ( + /\?\s*$/.test(prompt.trim()) && + infoPatterns.some((p) => p.test(prompt)) + ) { + return true; + } + return false; } /** @@ -506,7 +486,7 @@ export function isPastedContent( * compound technical token is a reference to an ARTIFACT (CLI subcommand, * file, property, path segment), not a request to run the workflow: * - * `oma ralph:verify` keyword + ':' + word (CLI subcommand) + * `oma ralph verify` keyword + ':' + word (CLI subcommand) * `ralph.md`, `ralph.exec-tier` keyword + '.' + word (file / property) * `.agents/workflows/ralph` word + '/' + keyword (path segment) * @@ -524,18 +504,46 @@ export function isTechnicalReference( ): boolean { // buildPatterns boundaries capture one non-word char on each side of the // keyword (unless the match touches ^ or $) — peel them off to locate the - // keyword span itself. CJK keywords compile without boundaries (lead/trail - // stay 0). - const lead = /^[^\w-]/.test(matchText) ? 1 : 0; - const trail = /[^\w-]$/.test(matchText) ? 1 : 0; - const kStart = matchIndex + lead; - const kEnd = matchIndex + matchText.length - trail; - const prev = kStart > 0 ? (text[kStart - 1] ?? "") : ""; - const prev2 = kStart > 1 ? (text[kStart - 2] ?? "") : ""; - const next = text[kEnd] ?? ""; - const next2 = text[kEnd + 1] ?? ""; - if ((next === ":" || next === ".") && /\w/.test(next2)) return true; - if (prev === "/" && /\w/.test(prev2)) return true; + // keyword token itself. + const m = matchText.match(/^([^\w-]?)(.*?)([^\w-]?)$/); + const leadingNonWord = m?.[1]?.length ?? 0; + const token = m?.[2] ?? matchText; + const tokenStart = matchIndex + leadingNonWord; + const tokenEnd = tokenStart + token.length; + + const charBefore = tokenStart > 0 ? text[tokenStart - 1] : ""; + const charAfter = tokenEnd < text.length ? text[tokenEnd] : ""; + + // 1. Path segment: preceded by '/' AND a word char before that slash + // (excludes leading-slash invocations like "/ralph" where charBefore-1 is start or space). + if ( + charBefore === "/" && + tokenStart >= 2 && + /[\w-]/.test(text[tokenStart - 2] ?? "") + ) { + return true; + } + + // 2. CLI subcommand: followed by ':' AND a word char after that colon + // (excludes prose colons like "ralph: do this" where charAfter+1 is space). + if ( + charAfter === ":" && + tokenEnd + 1 < text.length && + /[\w-]/.test(text[tokenEnd + 1] ?? "") + ) { + return true; + } + + // 3. File extension or property: followed by '.' AND a word char after that dot + // (excludes sentence-ending periods like "run ralph." where charAfter+1 is space/end). + if ( + charAfter === "." && + tokenEnd + 1 < text.length && + /[\w-]/.test(text[tokenEnd + 1] ?? "") + ) { + return true; + } + return false; } @@ -558,6 +566,8 @@ const QUESTION_PATTERNS: RegExp[] = [ /^.*뭐가\s*있/, /^.*어떤\s*(게|것|거)\s*있/, /^.*차이가?\s*뭐/, + /^.*(버그|문제|오류|에러)\s*(임|인가|인가요|야|이야|인지|\?)/, + /^.*(이거|이것|그거|그것)(도|는)?\s*(버그|문제|오류|에러)/, // Korean meta-continuation patterns (referring to prior discussion) /^.*그것도/, /^.*보강할/, @@ -567,10 +577,11 @@ const QUESTION_PATTERNS: RegExp[] = [ /^.*\banything worth\b/i, /^.*\bwhat.*(feature|difference|reference)/i, /^.*\bcompare\b/i, + /^.*\b(is this|is it|is that)\s+(a\s+)?(bug|issue|problem|error)\b/i, ]; /** - * Content-agnostic interrogative test. A first line that BOTH leads with an + * Content-agnostic interrogative test. A line that BOTH leads with an * interrogative word AND ends with '?' is a question *about* something, not a * command — regardless of the topic. This generalises to any subject * (including workflow names) without enumerating topic words, unlike @@ -580,20 +591,41 @@ const QUESTION_PATTERNS: RegExp[] = [ // loose contains — suppressing a question that merely contains a workflow name // is exactly the desired behaviour. const INTERROGATIVE_WORD = - /(?:왜|어째서|어떻게|무슨|무엇|뭐|뭔|뭣|어디|언제|누가|누구|어느|\bwhy\b|\bwhats?\b|\bhow\b|\bwhen\b|\bwhere\b|\bwhich\b|\bwhose\b)/i; + /(?:왜|어째서|어떻게|무슨|무엇|뭐|뭔|뭣|어디|언제|누가|누구|어느|버그|문제|에러|맞나|인가|인지|맞아|인가요|\bwhy\b|\bwhats?\b|\bhow\b|\bwhen\b|\bwhere\b|\bwhich\b|\bwhose\b|\bbug\b|\bissue\b|\bproblem\b|\berror\b)/i; function isInterrogativeSentence(line: string): boolean { return /\?\s*$/.test(line) && INTERROGATIVE_WORD.test(line); } +function firstAndLastLines(prompt: string): [string, string] { + const lines = prompt + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + return [lines[0] ?? "", lines[lines.length - 1] ?? ""]; +} + export function isAnalyticalQuestion(prompt: string): boolean { - const firstLine = (prompt.split("\n")[0] ?? "").trim(); + const [firstLine, lastLine] = firstAndLastLines(prompt); return ( isInterrogativeSentence(firstLine) || - QUESTION_PATTERNS.some((p) => p.test(firstLine)) + isInterrogativeSentence(lastLine) || + QUESTION_PATTERNS.some((p) => p.test(firstLine) || p.test(lastLine)) ); } +/** + * True when the first or last line ends with '?'. Broader than + * isAnalyticalQuestion: it also covers yes/no questions with no interrogative + * word ("Does this work on Windows?", "계속해줄래?"). Used only to keep + * NATURAL-LANGUAGE matches of persistent workflows from firing — an explicit + * invocation phrased politely ("ultrawork로 해줄래?") still activates. + * Callers pass NFKC-normalized text, so a fullwidth '?' is already '?'. + */ +export function hasQuestionLine(prompt: string): boolean { + return firstAndLastLines(prompt).some((line) => /\?\s*$/.test(line)); +} + export function stripCodeBlocks(text: string): string { return text .replace(/(`{3,})[^\n]*\n[\s\S]*?\1/g, "") // multiline fenced blocks (3+ backticks, matched closing) @@ -716,12 +748,19 @@ function activateMode( projectDir: string, workflow: string, sessionId: string, + omaSid?: string | null, ): 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, activatedAt: new Date().toISOString(), reinforcementCount: 0, + ...(omaSid ? { omaSid } : {}), }; writeFileSync( join(getStateDir(projectDir), `${workflow}-state-${sessionId}.json`), @@ -737,18 +776,20 @@ async function activateL1WorkflowSession( category = "main", ): Promise { try { - const [{ setActiveSession }, { createEventId, emitEvent }] = - await Promise.all([ - import("./state-marker.ts"), - import("./state-emit.ts"), - ]); - const sid = `oma-${createEventId()}`; + const [ + { setActiveSession }, + { createSessionId, emitEvent, vendorHomePayload }, + ] = await Promise.all([ + import("./state-marker.ts"), + import("./state-emit.ts"), + ]); + const sid = createSessionId(); setActiveSession(projectDir, category, sid); await emitEvent(projectDir, sid, { kind: "session.created", vendor, vendorSid, - payload: { workflow, category }, + payload: { workflow, category, ...vendorHomePayload(vendor) }, }); return sid; } catch (e) { @@ -774,11 +815,12 @@ export const DEACTIVATION_PHRASES: Record = { pl: ["workflow zakończony", "workflow ukończony"], }; -export function isDeactivationRequest(prompt: string, lang: string): boolean { - const phrases = [ - ...(DEACTIVATION_PHRASES.en ?? []), - ...(lang !== "en" ? (DEACTIVATION_PHRASES[lang] ?? []) : []), - ]; +export function isDeactivationRequest(prompt: string): boolean { + // All languages merged, never gated by config language (same rationale as + // collectLangEntries): a user prompting in Korean must be able to say + // "워크플로우 완료" even when `language: en`. A phrase only matches a prompt + // actually written in that language, so merging cannot misfire. + const phrases = Object.values(DEACTIVATION_PHRASES).flat(); const normalized = normalizeForMatching(prompt); return phrases.some((phrase) => normalized.includes(normalizeForMatching(phrase)), @@ -808,12 +850,187 @@ 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 / question-gated natural language) applies to this match. */ + suppressed: boolean; + /** True when the matched keyword is one of the workflow's `explicit` + * invocations — rule #0, and the only way to activate persistent mode. */ + explicit?: 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): + * 0. An explicit invocation (the workflow's own name, `explicit` in + * triggers.json) beats any natural-language match: "orchestrate this + * step by step" names orchestrate even though "step by step" is longer. + * 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 (Boolean(a.explicit) !== Boolean(b.explicit)) { + return a.explicit ? -1 : 1; + } + 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; +} + +// ── Compiled trigger cache ──────────────────────────────────── + +interface CompiledWorkflow { + workflow: string; + def: TriggerWorkflowDef; + declarationIndex: number; + /** Lowercased `explicit` invocations (persistent workflows only). */ + explicit: ReadonlySet; + keywordEntries: KeywordPatternEntry[]; + rawEntries: RawPatternEntry[]; +} + +interface CompiledTriggers { + workflows: CompiledWorkflow[]; + infoPatterns: RegExp[]; + excluded: ReadonlySet; +} + +// Regexes are compiled without the `g` flag, so exec() is stateless and the +// compiled set can be shared across run() calls (the in-process `oma hook +// run` path and long-lived test workers call run() many times). +const compiledCache = new WeakMap(); + +export function compileTriggers(config: TriggerConfig): CompiledTriggers { + const cached = compiledCache.get(config); + if (cached) return cached; + const compiled: CompiledTriggers = { + workflows: Object.entries(config.workflows).map( + ([workflow, def], declarationIndex) => ({ + workflow, + def, + declarationIndex, + explicit: new Set( + (def.persistent ? (def.explicit ?? []) : []).map((kw) => + kw.toLowerCase(), + ), + ), + keywordEntries: buildPatternEntries(def.keywords), + rawEntries: buildRawPatternEntries(def.patterns), + }), + ), + infoPatterns: buildInformationalPatterns(config), + excluded: new Set(config.excludedWorkflows), + }; + compiledCache.set(config, compiled); + return compiled; +} + +/** + * Injected context for a winning match. Explicit invocations and + * non-persistent workflows start immediately; a persistent workflow inferred + * from natural language is only suggested — persistent mode is not active, so + * the agent decides whether the request really calls for it. + */ +export function buildWorkflowContext( + workflow: string, + options: { persistent: boolean; explicit: boolean; explicitExample?: string }, +): string[] { + const header = `[OMA WORKFLOW: ${workflow.toUpperCase()}]`; + const doc = `\`.agents/workflows/${workflow}.md\``; + if (options.persistent && !options.explicit) { + const example = options.explicitExample + ? ` (e.g. "${options.explicitExample}")` + : ""; + return [ + header, + `User intent may match the /${workflow} workflow (inferred from natural language, not an explicit invocation).`, + `If the request clearly calls for it, read and follow ${doc}; otherwise handle the request directly.`, + `Persistent mode was NOT activated: it starts only when the user names the workflow explicitly${example}.`, + ]; + } + return [ + header, + `User intent matches the /${workflow} workflow.`, + `Read and follow ${doc} step by step.`, + `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, + ]; +} + // ── Pure handler (canonical ABI) ───────────────────────────── /** * Pure decision function — the single logic source for keyword detection. * - * Called in-process by `oma hook` dispatch (Task 3+) and by the standalone + * Called in-process by `oma hook run` dispatch (Task 3+) and by the standalone * `main()` entry below (pi subprocess path). Both paths share exactly this * code; no business logic is duplicated. * @@ -834,99 +1051,167 @@ 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); + const config = TRIGGERS; // Check for deactivation request before workflow detection - if (isDeactivationRequest(prompt, lang)) { + if (isDeactivationRequest(prompt)) { deactivateAllPersistentModes(projectDir, sessionId); - // Grok's resume context lives in a session-start file, not L1 stdout — clear it. - if (vendor === "grok") clearGrokContext(projectDir); return null; } - const infoPatterns = buildInformationalPatterns(config); + const compiled = compileTriggers(config); + const { infoPatterns } = compiled; // Guard 2: Strip code blocks, inline code, and pasted system-echo blocks // before scanning for keywords. NFKC normalization collapses fullwidth Latin. const cleaned = normalizeForMatching( stripSystemEchoes(stripCodeBlocks(prompt)), ); - const excluded = new Set(config.excludedWorkflows); // Guard 3: Load reinforcement suppression state const kwState = loadKwState(projectDir); // Skip persistent workflows entirely if the prompt is an analytical question const analytical = isAnalyticalQuestion(cleaned); - - for (const [workflow, def] of Object.entries(config.workflows)) { - if (excluded.has(workflow)) continue; - if (shouldSkipAllWorkflows(cleaned)) continue; + // Yes/no questions ("Does this work on Windows?") keep natural-language + // persistent matches from firing; explicit invocations are exempt. + const questionLike = hasQuestionLine(cleaned); + + // 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[] = []; + + for (const { + workflow, + def, + declarationIndex, + explicit: explicitKeywords, + keywordEntries, + rawEntries, + } of compiled.workflows) { + if (compiled.excluded.has(workflow)) 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, + explicit: boolean, + ) => { + 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(); + // A persistent workflow inferred from natural language inside a + // question is not a request to start it. + const questionGated = def.persistent && !explicit && questionLike; + + 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 || questionGated, + explicit, + }); + }; + + for (const { regex, keyword } of keywordEntries) { + considerMatch( + regex, + keyword, + explicitKeywords.has(keyword.toLowerCase()), + ); + } + for (const { regex, source } of rawEntries) { + considerMatch(regex, source, false); + } + } - 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; + const explicit = winner.explicit === true; + + // Activate the L1 session first so its sid can be recorded in the + // persistent-mode state file (the Stop hook emits gate events under it). + const omaSid = await activateL1WorkflowSession( + projectDir, + workflow, + vendor, + sessionId, + ); + // Only an explicit invocation enables persistent mode (the Stop hook blocks + // while the state file exists). Natural-language matches such as "keep + // going" or "implement the login feature" only suggest the workflow. + if (winner.persistent && explicit) { + activateMode(projectDir, workflow, sessionId, omaSid); + } + const updatedState = recordKwTrigger(kwState, workflow); + saveKwState(projectDir, updatedState); + + const contextLines = buildWorkflowContext(workflow, { + persistent: winner.persistent, + explicit, + explicitExample: config.workflows[workflow]?.explicit?.[0], + }); - 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) ── @@ -943,10 +1228,10 @@ async function main() { // Guard 1: Only process genuine user prompts — skip agent-generated content if (!isGenuineUserPrompt(input)) process.exit(0); - const vendor = detectVendor(input); + const vendor = detectVendorFromInput(input, "prompt", import.meta.filename); 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/memory-adapter.ts b/.agents/hooks/core/memory-adapter.ts new file mode 100644 index 0000000..b168936 --- /dev/null +++ b/.agents/hooks/core/memory-adapter.ts @@ -0,0 +1,57 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** Runtime dependency injection for the CLI and a local-only standalone fallback. */ +export interface HookMemoryAdapter { + recall( + query: string, + limit: number, + projectDir?: string, + ): Promise>; + observe(payload: { + sessionId: string; + content: string; + source: string; + projectDir?: string; + }): Promise; +} + +const adapters = new AsyncLocalStorage(); +const localOnly: HookMemoryAdapter = { + recall: async () => [], + observe: async () => true, +}; + +export function currentMemoryAdapter( + projectDir = process.cwd(), +): HookMemoryAdapter | undefined { + const adapter = adapters.getStore(); + if (adapter) return adapter; + let directory = resolve(projectDir); + while (true) { + const path = join(directory, ".agents", "state", "provider-selection.json"); + if (existsSync(path)) { + try { + return JSON.parse(readFileSync(path, "utf8")).semantic_memory === + "agentmemory" + ? undefined + : localOnly; + } catch { + return localOnly; + } + } + // A nested OMA project owns its configuration independently of its parent. + if (existsSync(join(directory, ".agents", "oma-config.yaml"))) + return undefined; + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} +export function withMemoryAdapter( + adapter: HookMemoryAdapter, + run: () => T, +): T { + return adapters.run(adapter, run); +} diff --git a/.agents/hooks/core/persistent-mode.ts b/.agents/hooks/core/persistent-mode.ts index 9e434d9..84182e7 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. @@ -13,16 +13,18 @@ * exit 2 = block stop */ +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, + statSync, unlinkSync, 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 } 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,19 +37,380 @@ import type { ModeState, Vendor, } from "./types.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; const MAX_REINFORCEMENTS = 5; const STALE_HOURS = 2; -function detectLanguage(projectDir: string): string { - const prefsPath = join(projectDir, ".agents", "oma-config.yaml"); - if (!existsSync(prefsPath)) return "en"; +/** + * Persistent state older than this belongs to a session that is gone. A live + * session's own Stop releases its workflow after STALE_HOURS, so a file this + * old is an orphan (crashed or closed session, pre-fix `-unknown` files). + */ +const ORPHAN_STATE_HOURS = 24; + +// ── Goal contract: deterministic stop gate + wall-clock budget ─ +// (design-prime-agent-adoption Track B — no-exec-of-agent-writable-strings) + +/** + * The only gate values the Stop hook will ever execute. Each maps to a + * package.json script of the same name, run as an argv array WITHOUT a shell. + * The gate value lives in an agent-writable state file; executing anything + * outside this allowlist would be an arbitrary-command path that bypasses the + * PreToolUse permission layer. Never widen this to free-form strings. + */ +const GATE_KEYWORDS = new Set(["typecheck", "test", "lint"]); + +/** + * Hard cap on a gate run. The Stop budget chain, smallest to largest, keeps a + * gate inside the hook so THIS code stops it — never the vendor, which would + * kill the hook silently and fail open with nothing recorded: + * + * GATE_TIMEOUT_MS (25s) + * < persistent-mode Stop handler timeout in every + * `.agents/hooks/variants/*.json` (30s — `oma hook run` races it, and + * the pi/opencode bridges spawn this script with the same budget) + * < vendor Stop timeout = chain sum + 5s margin + * (`chainTimeoutSeconds` in cli/platform/hooks-composer.ts → 40s). + * + * `cli/__tests__/hook-timeout-budget.test.ts` locks the chain. + */ +export const GATE_TIMEOUT_MS = 25_000; + +/** Below this much remaining budget a gate is deferred instead of started. */ +const MIN_GATE_RUN_MS = 1_000; + +/** After the gate exits, wait this long for its pipes to drain. */ +const EXIT_FLUSH_GRACE_MS = 500; + +/** Tail of gate output carried back into the block reason. */ +const GATE_OUTPUT_TAIL_CHARS = 2_000; + +/** Rolling output buffer; trimmed to its latter half when exceeded. */ +const GATE_OUTPUT_BUFFER_CHARS = 64_000; + +/** Signals that, aimed at this hook process, must take the gate run down too. */ +const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGINT", "SIGHUP"]; + +/** + * Effective gate budget in ms. `OMA_GATE_TIMEOUT_MS` may only LOWER it + * (tests, slow CI): raising it past the handler budget would bring back the + * silent vendor kill. + */ +export function gateTimeoutMs(): number { + const raw = Number(process.env.OMA_GATE_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 + ? Math.min(raw, GATE_TIMEOUT_MS) + : GATE_TIMEOUT_MS; +} + +/** `25s`, `0.5s` — budget wording for block reasons. */ +function formatSeconds(ms: number): string { + return `${Number((ms / 1000).toFixed(1))}s`; +} + +/** + * Resolve an allowlisted gate keyword to a package-runner argv, or null when + * the keyword is not allowlisted, package.json is absent, or it defines no + * script of that name. Pure node:fs — no shell, no third-party imports. + */ +export function resolveGateArgv( + gateKeyword: string, + projectDir: string, +): string[] | null { + if (!GATE_KEYWORDS.has(gateKeyword)) return null; + const pkgPath = join(projectDir, "package.json"); + if (!existsSync(pkgPath)) return null; try { - const content = readFileSync(prefsPath, "utf-8"); - const match = content.match(/^language:\s*(\S+)/m); - return match?.[1] ?? "en"; + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + scripts?: Record; + }; + if (typeof pkg.scripts?.[gateKeyword] !== "string") return null; } catch { - return "en"; + return null; + } + if ( + existsSync(join(projectDir, "bun.lock")) || + existsSync(join(projectDir, "bun.lockb")) + ) { + return ["bun", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "pnpm-lock.yaml"))) { + return ["pnpm", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "yarn.lock"))) { + return ["yarn", gateKeyword]; + } + return ["npm", "run", gateKeyword]; +} + +export interface GateRunResult { + passed: boolean; + timedOut: boolean; + outputTail: string; +} + +/** + * Stop a gate run and everything it forked. POSIX gates lead their own + * process group (spawned detached), so the negative pid also reaches the + * test-runner workers; Windows has no process groups — taskkill /T walks the + * tree. `leaderAlive: false` (after exit) never falls back to the bare pid, + * which the OS may already have handed to an unrelated process. + */ +function killGateTree(pid: number | undefined, leaderAlive: boolean): void { + if (!pid) return; + if (process.platform === "win32") { + if (!leaderAlive) return; + try { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } catch { + // already gone + } + return; + } + try { + process.kill(-pid, "SIGKILL"); + } catch { + if (!leaderAlive) return; + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } +} + +/** + * Run a resolved gate argv with a hard timeout. No shell involved. Async so + * the dispatcher's per-handler timeout can still preempt it; on timeout, or + * when this hook process is itself signalled (vendor kill, Ctrl-C), the whole + * gate process tree is killed — no orphaned test runners. + */ +export function runGateCommand( + argv: string[], + projectDir: string, + timeoutMs: number = gateTimeoutMs(), +): Promise { + const [command, ...args] = argv; + return new Promise((resolve) => { + let output = ""; + let timedOut = false; + let settled = false; + let exitCode: number | null = null; + let spawnError = ""; + let child: ChildProcess | undefined; + let timer: ReturnType | undefined; + let flushTimer: ReturnType | undefined; + + const append = (chunk: Buffer | string): void => { + output += chunk.toString(); + if (output.length > GATE_OUTPUT_BUFFER_CHARS) { + output = output.slice(-GATE_OUTPUT_BUFFER_CHARS / 2); + } + }; + const onSignal = (signal: NodeJS.Signals): void => { + killGateTree(child?.pid, true); + // Keep the signal's default effect when nothing else handles it. + if (process.listenerCount(signal) <= 1) { + detachSignals(); + process.kill(process.pid, signal); + } + }; + const detachSignals = (): void => { + for (const signal of FORWARDED_SIGNALS) { + process.removeListener(signal, onSignal); + } + }; + const finish = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(flushTimer); + detachSignals(); + if (spawnError) append(`\n${spawnError}`); + resolve({ + passed: exitCode === 0 && !timedOut && !spawnError, + timedOut, + outputTail: output.trim().slice(-GATE_OUTPUT_TAIL_CHARS), + }); + }; + + try { + child = spawn(command as string, args, { + cwd: projectDir, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + spawnError = error instanceof Error ? error.message : String(error); + finish(); + return; + } + const gate = child; + for (const signal of FORWARDED_SIGNALS) process.on(signal, onSignal); + gate.stdout?.on("data", append); + gate.stderr?.on("data", append); + timer = setTimeout(() => { + timedOut = true; + killGateTree(gate.pid, true); + }, timeoutMs); + gate.on("error", (error) => { + spawnError = error.message; + finish(); + }); + gate.on("exit", (code) => { + exitCode = code; + // The verdict is the leader's exit code. Give its pipes a moment to + // drain, then settle even if a straggler still holds stdout open. + flushTimer = setTimeout(() => { + killGateTree(gate.pid, false); + finish(); + }, EXIT_FLUSH_GRACE_MS); + }); + gate.on("close", (code) => { + exitCode ??= code; + // Reap stragglers the runner left in its group (leaked workers, servers). + killGateTree(gate.pid, false); + finish(); + }); + }); +} + +/** True when the goal's wall-clock budget (from activatedAt) is exhausted. */ +export function isBudgetExhausted(state: ModeState): boolean { + const minutes = state.goal?.budget?.wallClockMinutes; + if ( + typeof minutes !== "number" || + !Number.isFinite(minutes) || + minutes <= 0 + ) { + return false; + } + const elapsedMs = Date.now() - new Date(state.activatedAt).getTime(); + return elapsedMs >= minutes * 60_000; +} + +/** + * Emit a gate event onto the L1 trail recorded at activation. Best-effort: + * older state files carry no omaSid, and event emission must never break the + * Stop decision itself. + */ +async function emitGateEvent( + projectDir: string, + state: ModeState, + kind: "gate.passed" | "gate.failed", + payload: Record, +): Promise { + if (!state.omaSid) return; + try { + const { emitEvent } = await import("./state-emit.ts"); + await emitEvent(projectDir, state.omaSid, { + kind, + payload: { workflow: state.workflow, ...payload }, + }); + } catch { + // best-effort — never let event I/O change the stop decision + } +} + +interface PendingEnding { + status: "completed" | "failed"; + reason: string; +} + +interface SessionEnding extends PendingEnding { + state: ModeState; +} + +type RecoverableModeState = ModeState & { pendingEnding?: PendingEnding }; + +async function finishPersistentSessions( + projectDir: string, + sessionId: string, + endings: SessionEnding[], +): Promise { + const bySid = new Map(); + for (const ending of endings) { + const sid = ending.state.omaSid; + if (!sid) continue; + const previous = bySid.get(sid); + if (previous?.status !== "failed") bySid.set(sid, ending); + } + for (const [sid, ending] of bySid) { + // Several persistent workflows can share one L1 session. Keep it active + // while any sibling mode still needs work or a gate retry. + if ( + loadPersistentWorkflows().some( + (workflow) => + readModeState(projectDir, workflow, sessionId)?.omaSid === sid, + ) + ) + continue; + const pending: PendingEnding = { + status: ending.status, + reason: ending.reason, + }; + try { + const { emitEvent, readEvents } = await import("./state-emit.ts"); + const previousStopFailure = + ending.reason === "workflow_done" + ? undefined + : readEvents(projectDir, sid).findLast( + (event) => + event.kind === "gate.failed" && + [ + "budget_exhausted", + "stale_state", + "reinforcement_exhausted", + ].includes(String(event.payload?.reason ?? "")), + ); + if (previousStopFailure) { + pending.status = "failed"; + pending.reason = String(previousStopFailure.payload?.reason); + } + await emitEvent(projectDir, sid, { + kind: "session.ended", + payload: { + workflow: ending.state.workflow, + status: pending.status, + reason: pending.reason, + }, + }); + } catch { + // Preserve a terminal retry without overwriting a newly activated mode. + const path = join( + getStateDir(projectDir), + `${ending.state.workflow}-state-${sessionId}.json`, + ); + try { + if (!existsSync(path)) + writeFileSync( + path, + JSON.stringify( + { ...ending.state, pendingEnding: pending }, + null, + 2, + ), + { flag: "wx" }, + ); + } catch (error) { + process.stderr.write( + `[oma] Could not retain session end retry for ${sid}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } + continue; + } + // Summary export is optional and follows successful terminal persistence. + try { + const { exportSessionSummary } = await import("./session-summary.ts"); + await exportSessionSummary({ projectDir, sid }); + } catch { + // Optional export failure must not undo the terminal event or stop. + } } } @@ -70,78 +433,12 @@ function loadPersistentWorkflows(): string[] { // ── Vendor Detection ────────────────────────────────────────── -function detectVendor(input: Record): Vendor { - const event = input.hook_event_name as string | undefined; - const hookEventName = input.hookEventName as string | undefined; - - if (process.env.GROK_WORKSPACE_ROOT || hookEventName?.includes("stop")) { - if (process.env.GROK_WORKSPACE_ROOT) return "grok"; - } - - if ( - process.env.KIRO_PROJECT_DIR || - event === "stop" || - hookEventName === "stop" - ) { - return "kiro"; - } - - // agy (Antigravity) Stop sends no hook_event_name; detect by stdin shape. - 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"; - } - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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 ); } @@ -155,14 +452,14 @@ function readModeState( projectDir: string, workflow: string, sessionId: string, -): ModeState | null { +): RecoverableModeState | null { const path = join( getStateDir(projectDir), `${workflow}-state-${sessionId}.json`, ); if (!existsSync(path)) return null; try { - return JSON.parse(readFileSync(path, "utf-8")) as ModeState; + return JSON.parse(readFileSync(path, "utf-8")) as RecoverableModeState; } catch { return null; } @@ -202,6 +499,64 @@ export function deactivateAllForSession( } } +/** activatedAt of a state file, falling back to its mtime when unreadable. */ +function stateTimestampMs(path: string): number { + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as { + activatedAt?: unknown; + }; + const ms = + typeof parsed?.activatedAt === "string" + ? Date.parse(parsed.activatedAt) + : Number.NaN; + if (Number.isFinite(ms)) return ms; + } catch { + // corrupt or half-written — judge by mtime instead + } + return statSync(path).mtimeMs; +} + +/** + * Remove persistent-workflow state files of OTHER sessions older than + * ORPHAN_STATE_HOURS. Only that session's own Stop deletes its file, so a + * session that ended without one (crash, closed terminal) left it behind + * forever. The current session's file is never touched. Returns the removed + * file names; best-effort, never throws. + */ +export function sweepOrphanedModeStates( + projectDir: string, + currentSessionId: string, + now: number = Date.now(), +): string[] { + const stateDir = getStateDir(projectDir); + let files: string[]; + try { + files = readdirSync(stateDir); + } catch { + return []; + } + const prefixes = loadPersistentWorkflows().map((w) => `${w}-state-`); + const cutoff = now - ORPHAN_STATE_HOURS * 60 * 60 * 1000; + const removed: string[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const prefix = prefixes.find((p) => file.startsWith(p)); + if (!prefix) continue; + const sid = file.slice(prefix.length, -".json".length); + if (!sid || sid === currentSessionId) continue; + const path = join(stateDir, file); + try { + if (stateTimestampMs(path) < cutoff) { + unlinkSync(path); + removed.push(file); + } + } catch { + // raced with another remover or unreadable — leave it + } + } + return removed; +} + function incrementReinforcement( projectDir: string, workflow: string, @@ -225,12 +580,7 @@ function incrementReinforcement( * the stop should be blocked, or `null` when no workflow is active / all are * stale/exhausted. * - * NOTE: The deactivation-via-response-text check (reading `prompt_response`, - * `response`, `content` etc. from raw stdin) is not representable in the - * canonical `HookInput { kind: "stop"; cwd }` shape — those fields are absent. - * That check stays in the standalone `main()` path. When dispatched via - * `oma hook`, the dispatch layer is responsible for passing a pre-checked input - * (or extending HookInput in a future revision). + * Both canonical and standalone callers pass response text to this handler. * * `ctx.cwd` must be the resolved git-root project directory; * `ctx.sid` is the vendor session id. @@ -241,44 +591,194 @@ 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; + + // Every gate in this Stop shares one budget so the chain stays inside the + // persistent-mode handler timeout (see GATE_TIMEOUT_MS). + const gateBudgetMs = gateTimeoutMs(); + const gateDeadline = Date.now() + gateBudgetMs; + let gateRanThisStop = false; + + // Housekeeping: other sessions' long-dead persistent state. + sweepOrphanedModeStates(projectDir, sessionId); + + // 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 - // mode could not be deactivated via the central `oma hook` dispatch. + // mode could not be deactivated via the central `oma hook run` dispatch. if (input.responseText) { - const lang = detectLanguage(projectDir); - if (isDeactivationRequest(input.responseText, lang)) { + if (isDeactivationRequest(input.responseText)) { + const endings = loadPersistentWorkflows() + .map((workflow) => readModeState(projectDir, workflow, sessionId)) + .filter((state): state is ModeState => state !== null) + .map( + (state): SessionEnding => ({ + state, + status: "completed", + reason: "workflow_done", + }), + ); deactivateAllForSession(projectDir, sessionId); + await finishPersistentSessions(projectDir, sessionId, endings); return null; } } const persistentWorkflows = loadPersistentWorkflows(); + const endings: SessionEnding[] = []; for (const workflow of persistentWorkflows) { const state = readModeState(projectDir, workflow, sessionId); if (!state) continue; + if ( + state.pendingEnding && + (state.pendingEnding.status === "completed" || + state.pendingEnding.status === "failed") && + typeof state.pendingEnding.reason === "string" && + state.pendingEnding.reason.trim() + ) { + deactivate(projectDir, workflow, sessionId); + endings.push({ state, ...state.pendingEnding }); + continue; + } + + // (1) Wall-clock budget: exhausted → failed terminal stop. A machine + // verdict, not model discretion — the stop is allowed and the exhaustion + // is recorded on the L1 trail. + if (isBudgetExhausted(state)) { + deactivate(projectDir, workflow, sessionId); + await emitGateEvent(projectDir, state, "gate.failed", { + gate: "budget", + reason: "budget_exhausted", + summary: `wall-clock budget (${state.goal?.budget?.wallClockMinutes}m) exhausted for /${workflow}; stopping with failed status`, + }); + endings.push({ state, status: "failed", reason: "budget_exhausted" }); + continue; + } + if (isStale(state) || state.reinforcementCount >= MAX_REINFORCEMENTS) { deactivate(projectDir, workflow, sessionId); + const reason = isStale(state) ? "stale_state" : "reinforcement_exhausted"; + await emitGateEvent(projectDir, state, "gate.failed", { + gate: "persistent-mode", + reason, + summary: `persistent stop released for /${workflow}: ${reason}`, + }); + endings.push({ + state, + status: "failed", + reason, + }); continue; } + const stateFile = `.agents/state/${workflow}-state-${sessionId}.json`; + + // (2) Deterministic completion gate. Only allowlisted keywords resolve to + // a runnable argv; anything else (including free-form shell strings an + // agent may have written into the state file) is NEVER executed and falls + // through to the plain reinforcement block below. + const gateKeyword = state.goal?.completion?.gate; + let ignoredGateNote = ""; + if (gateKeyword) { + const argv = resolveGateArgv(gateKeyword, projectDir); + if (argv) { + const budgetMs = Math.max(1, gateDeadline - Date.now()); + if (gateRanThisStop && budgetMs < MIN_GATE_RUN_MS) { + // An earlier workflow's gate spent this Stop's budget. That gate's + // workflow is now settled, so the next Stop runs this one in full — + // not a failure, no reinforcement charged. + await finishPersistentSessions(projectDir, sessionId, endings); + return { + type: "block", + reason: [ + `[OMA PERSISTENT MODE: ${workflow.toUpperCase()}]`, + `Stop gate '${gateKeyword}' deferred: another gate used this stop's ${formatSeconds(gateBudgetMs)} gate budget.`, + `Finish your current step and stop again — the gate runs then.`, + `To abandon instead: delete ${stateFile} or say "workflow done".`, + ].join("\n"), + }; + } + gateRanThisStop = true; + const gate = await runGateCommand(argv, projectDir, budgetMs); + if (gate.passed) { + // The gate is the mechanical proof of completion: allow the stop. + deactivate(projectDir, workflow, sessionId); + await emitGateEvent(projectDir, state, "gate.passed", { + gate: gateKeyword, + summary: `stop gate '${gateKeyword}' passed for /${workflow}`, + }); + endings.push({ + state, + status: "completed", + reason: "completion_gate_passed", + }); + continue; + } + // Failure and timeout both count toward MAX_REINFORCEMENTS so a + // permanently red (or permanently slow) gate cannot block stops + // forever. Recorded before anything else can go wrong. + incrementReinforcement(projectDir, workflow, sessionId, state); + const budget = formatSeconds(gateBudgetMs); + await emitGateEvent(projectDir, state, "gate.failed", { + gate: gateKeyword, + timedOut: gate.timedOut, + ...(gate.timedOut ? { budgetMs } : {}), + summary: gate.timedOut + ? `stop gate '${gateKeyword}' timed out after ${budget} (Stop-hook gate budget) for /${workflow}` + : `stop gate '${gateKeyword}' failed for /${workflow}`, + }); + const quicker = ["lint", "typecheck"] + .filter((keyword) => keyword !== gateKeyword) + .map((keyword) => `\`oma goal set --gate ${keyword}\``) + .join(" or "); + const reason = [ + `[OMA PERSISTENT MODE: ${workflow.toUpperCase()}]`, + gate.timedOut + ? `Stop gate '${gateKeyword}' timed out after ${budget} — the Stop-hook gate budget — and its process tree was stopped (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).` + : `Stop gate '${gateKeyword}' FAILED (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).`, + gate.timedOut + ? `A gate runs inside the Stop hook and must finish within ${budget}: make the \`${gateKeyword}\` script faster (e.g. only the affected tests), or switch to a quicker gate with ${quicker}.` + : `Fix the failures below, then finish the workflow — the stop is allowed only when the gate passes.`, + gate.outputTail + ? `--- gate output (tail) ---\n${gate.outputTail}` + : "", + `To abandon instead: delete ${stateFile} or say "workflow done".`, + ] + .filter(Boolean) + .join("\n"); + await finishPersistentSessions(projectDir, sessionId, endings); + return { type: "block", reason }; + } + ignoredGateNote = `Note: configured stop gate ${JSON.stringify(gateKeyword)} is not an allowed keyword (typecheck|test|lint) or has no matching package.json script — it was NOT executed.`; + } + incrementReinforcement(projectDir, workflow, sessionId, state); - const stateFile = `.agents/state/${workflow}-state-${sessionId}.json`; const reason = [ `[OMA PERSISTENT MODE: ${workflow.toUpperCase()}]`, `The /${workflow} workflow is still active (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).`, `Continue executing the workflow. If all tasks are genuinely complete:`, ` 1. Delete the state file: Bash \`rm ${stateFile}\``, - ` 2. Or ask the user to say "워크플로우 완료" / "workflow done"`, - ].join("\n"); + ignoredGateNote, + ] + .filter(Boolean) + .join("\n"); + await finishPersistentSessions(projectDir, sessionId, endings); return { type: "block", reason }; } + await finishPersistentSessions(projectDir, sessionId, endings); return null; } @@ -293,10 +793,9 @@ async function main() { process.exit(0); } - const vendor = detectVendor(input); + const vendor = detectVendorFromInput(input, "stop", import.meta.filename); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - const lang = detectLanguage(projectDir); // Check all text fields in stdin for deactivation phrases. // The assistant may have included "workflow done" in its response, @@ -304,7 +803,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, @@ -313,14 +812,12 @@ async function main() { .filter((v): v is string => typeof v === "string") .join(" "); - if (textToCheck && isDeactivationRequest(textToCheck, lang)) { - // Deactivate all persistent workflows for this session (shared helper). - deactivateAllForSession(projectDir, sessionId); - process.exit(0); - } - // Delegate to run() for the block decision — single logic source. - const hookInput: HookInput = { kind: "stop", cwd: projectDir }; + const hookInput: HookInput = { + kind: "stop", + cwd: projectDir, + responseText: textToCheck || undefined, + }; const ctxVal: HandlerCtx = { vendor, cwd: projectDir, sid: sessionId }; const result = await run(hookInput, ctxVal); diff --git a/.agents/hooks/core/prompt-input.ts b/.agents/hooks/core/prompt-input.ts new file mode 100644 index 0000000..f949f0f --- /dev/null +++ b/.agents/hooks/core/prompt-input.ts @@ -0,0 +1,55 @@ +// 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; + const head = trimmed.slice(0, 200); + return ( + head.includes(CROSS_SESSION_ENVELOPE) || + head.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/refactor-guard.ts b/.agents/hooks/core/refactor-guard.ts new file mode 100644 index 0000000..76bfa15 --- /dev/null +++ b/.agents/hooks/core/refactor-guard.ts @@ -0,0 +1,607 @@ +// Refactor guard — force a refactor pass when a turn leaves an edited code +// file over the line budget (default 500). Two-phase design: +// +// post_tool (PostToolUse) — SILENT RECORDER. After Edit/Write/etc. lands, +// the touched file path is appended to the session's state marker. Never +// blocks: mid-turn the agent may legitimately grow a file it is about to +// split, and interrupting every edit would also fight the very +// refactor-engineer subagent this guard dispatches (subagent tool calls +// fire the same hooks under the same session id). +// stop (Stop) — ENFORCER. When the turn tries to end, recorded files are +// re-counted; any still over budget blocks the stop with an instruction +// to dispatch the `refactor-engineer` subagent. The block reason re-enters +// the loop, so the turn cannot end until the file is split (bounded by +// MAX_STOP_BLOCKS per file to guarantee termination). +// +// Works with every hook vendor: Claude Code, Codex CLI, Qwen Code, +// Command Code, Kimi Code, Cursor (afterFileEdit), Grok CLI, Antigravity, +// Kiro. The recorder needs only the post-tool event to FIRE with tool info +// on stdin — its output may be ignored (kimi: fire-and-forget per source; +// agy: audit-only `{}` contract). Enforcement needs a Stop block channel: +// claude/codex/qwen/commandcode/kimi block via decision keys, cursor via +// followup_message, grok via {decision:"block"} (verified from the grok +// binary's embedded docs — Stop/SubagentStop CAN block), agy via +// {decision:"continue"}. kiro's Stop output is not processed by the host, +// so there the enforcer direct-dispatches `oma agent spawn +// refactor-engineer` (detached) instead of relying on the block reason. +// +// OFF by default (opt-in). The guard only fires when the project enables it: +// # .agents/oma-config.yaml +// refactor_guard: +// enabled: true # default false — forced refactor is opt-in +// max_lines: 500 # line budget (default 500) +// Defaults apply when the file or keys are absent. + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { agyConversationId, agyProjectDir, isAgyInput } from "./agy-input.ts"; +import { toPosixPath } from "./fs-utils.ts"; +import { makeBlockOutput } from "./hook-output.ts"; +import { atomicWriteJson } from "./state-marker.ts"; +import type { + HandlerCtx, + HandlerResult, + HookConfig, + HookInput, + Vendor, +} from "./types.ts"; +import { getProjectDir } from "./vendor-detect.ts"; + +// --- Defaults --- + +export const DEFAULT_MAX_LINES = 500; + +/** Stop blocks emitted per file before giving up (termination guarantee). */ +export const MAX_STOP_BLOCKS = 2; + +/** + * Vendors whose Stop hook output cannot block the stop (kiro: hook output is + * not processed — aws/amazon-q lineage; verified 2026-08). A blocking reason + * cannot force the model there, so the enforcer dispatches the refactor agent + * DIRECTLY via a detached `oma agent spawn refactor-engineer` on the first + * block of each offending file. + */ +const DIRECT_DISPATCH_VENDORS = new Set(["kiro"]); + +const MAIN_CONFIG_RELPATH = join(".agents", "oma-config.yaml"); +const STATE_RELDIR = join(".agents", "state", "refactor-guard"); + +/** + * File-editing tool names across vendor dialects, lowercase (matched + * case-insensitively — Command Code reports canonical display names like + * WRITE/EDIT). codex reports `apply_patch`, whose paths come from the patch + * body instead of a file_path input (see extractPatchPaths). + */ +const EDIT_TOOLS = new Set([ + "edit", // claude, codex alias, commandcode + "write", // claude, codex alias, commandcode + "notebookedit", // claude + "multiedit", // claude (legacy builds) + "write_file", // qwen, grok + "replace", // qwen + "edit_file", // generic, grok + "apply_patch", // codex, grok (codex patch format) + "writefile", // kimi (WriteFile) + "strreplacefile", // kimi (StrReplaceFile) + "write_to_file", // antigravity (args.TargetFile) + "replace_file_content", // antigravity (args.TargetFile) + "notebook_edit", // antigravity + "hashline_edit", // grok (args.path) + "create_file", // grok + "str_replace", // grok + "fs_write", // kiro +]); + +/** Extensions the guard treats as refactorable source code. */ +const CODE_EXTENSIONS = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "py", + "go", + "rs", + "java", + "kt", + "kts", + "swift", + "dart", + "rb", + "php", + "c", + "cc", + "cpp", + "h", + "hpp", + "cs", + "scala", + "vue", + "svelte", + "m", + "mm", +]); + +/** Path segments that mark generated / dependency / artifact trees. */ +const EXCLUDED_SEGMENTS = [ + "node_modules", + "dist", + "build", + "out", + "coverage", + "vendor", + ".git", + ".next", + ".agents/results", + ".agents/state", +]; + +// --- Config loading (regex-based, consistent with scm-guard's yaml handling) --- + +export interface GuardConfig { + enabled: boolean; + maxLines: number; +} + +/** + * Strip a trailing `# comment` and surrounding whitespace/quotes from a scalar. + * The config template documents every key with an inline comment, so a value + * copied straight out of it (`enabled: true # opt-in`) must still parse — + * a mismatch here fails silently back to "off", which reads as a broken flag. + */ +function scalarValue(raw: string): string { + return raw + .replace(/\s+#.*$/, "") + .trim() + .replace(/^["']|["']$/g, ""); +} + +/** + * Read `refactor_guard` from the config `oma hook run` loaded (CUE and local + * overlays included). Accepts the same YAML 1.1 boolean spellings as the + * standalone reader so a value never flips meaning between the two paths. + */ +export function guardConfigFromConfig(config: HookConfig): GuardConfig { + const result: GuardConfig = { enabled: false, maxLines: DEFAULT_MAX_LINES }; + const block = config.refactor_guard; + if (!block || typeof block !== "object" || Array.isArray(block)) { + return result; + } + const { enabled, max_lines: maxLines } = block as Record; + result.enabled = + enabled === true || + (typeof enabled === "string" && /^(true|yes|on)$/i.test(enabled.trim())); + if (typeof maxLines === "number" && Number.isInteger(maxLines)) { + if (maxLines >= 0) result.maxLines = maxLines; + } else if (typeof maxLines === "string" && /^\d+$/.test(maxLines.trim())) { + result.maxLines = Number.parseInt(maxLines, 10); + } + return result; +} + +/** + * Extract `enabled` / `max_lines` from the `refactor_guard:` block of + * oma-config.yaml without a yaml dependency — core handlers stay standalone. + * `config` (from `oma hook run`) replaces the file read when present. + */ +export function loadGuardConfig( + projectDir: string, + config?: HookConfig, +): GuardConfig { + if (config) return guardConfigFromConfig(config); + // Forced refactoring is opt-in: enabled stays false until the project sets + // `refactor_guard.enabled: true` in oma-config.yaml. + const defaults: GuardConfig = { enabled: false, maxLines: DEFAULT_MAX_LINES }; + const configPath = join(projectDir, MAIN_CONFIG_RELPATH); + if (!existsSync(configPath)) return defaults; + try { + const content = readFileSync(configPath, "utf-8"); + const lines = content.split(/\r?\n/); + const start = lines.findIndex((l) => /^refactor_guard:\s*(#.*)?$/.test(l)); + if (start === -1) return defaults; + const config = { ...defaults }; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (/^\s*(#|$)/.test(line)) continue; // comments / blanks inside block + if (!/^\s/.test(line)) break; // end of the indented block + const enabled = line.match(/^\s+enabled:\s*(\S.*)$/)?.[1]; + if (enabled) { + // YAML 1.1 booleans, since that is what the surrounding config uses. + const value = scalarValue(enabled).toLowerCase(); + if (/^(true|yes|on)$/.test(value)) config.enabled = true; + else if (/^(false|no|off)$/.test(value)) config.enabled = false; + } + const maxLines = line.match(/^\s+max_lines:\s*(\S.*)$/)?.[1]; + if (maxLines) { + const value = scalarValue(maxLines); + if (/^\d+$/.test(value)) config.maxLines = Number.parseInt(value, 10); + } + } + return config; + } catch { + return defaults; + } +} + +// --- File classification --- + +export function isRefactorableFile(relPath: string): boolean { + const posix = toPosixPath(relPath); + if (posix.startsWith("..")) return false; // outside the project + const ext = posix.match(/\.([^./]+)$/)?.[1]?.toLowerCase(); + if (!ext || !CODE_EXTENSIONS.has(ext)) return false; + if (posix.endsWith(".d.ts")) return false; // generated declarations + return !EXCLUDED_SEGMENTS.some( + (seg) => + posix === seg || + posix.startsWith(`${seg}/`) || + posix.includes(`/${seg}/`), + ); +} + +export function countLines(content: string): number { + if (content.length === 0) return 0; + const parts = content.split(/\r?\n/); + // A trailing newline yields one empty final element — not a line. + return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length; +} + +// --- Session state (touched files + per-file stop-block counts) --- + +interface GuardState { + /** Project-relative POSIX paths of code files edited this session. */ + touched: Record; + /** Stop blocks already emitted per file (capped at MAX_STOP_BLOCKS). */ + stopBlocks: Record; +} + +function statePath(projectDir: string, sid: string): string { + // sid comes from the vendor payload; sanitize for filesystem use. + const safeSid = sid.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"; + return join(projectDir, STATE_RELDIR, `${safeSid}.json`); +} + +function readState(projectDir: string, sid: string): GuardState { + const path = statePath(projectDir, sid); + if (!existsSync(path)) return { touched: {}, stopBlocks: {} }; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as GuardState; + return { + touched: parsed.touched ?? {}, + stopBlocks: parsed.stopBlocks ?? {}, + }; + } catch { + return { touched: {}, stopBlocks: {} }; + } +} + +// --- Tool input parsing --- + +/** + * Resolve edited file paths from the tool input across vendor dialects. + * Most tools carry a single path field; codex's `apply_patch` embeds paths + * in the patch body (`*** Add File:` / `*** Update File:` markers). + */ +export function resolveEditedPaths( + toolName: string, + toolInput: Record, +): string[] { + if (toolName.toLowerCase() === "apply_patch") { + for (const value of Object.values(toolInput)) { + if (typeof value !== "string" || !value.includes("*** ")) continue; + const paths = extractPatchPaths(value); + if (paths.length > 0) return paths; + } + return []; + } + const cand = + toolInput.file_path ?? + toolInput.filePath ?? + toolInput.notebook_path ?? + toolInput.path ?? + // antigravity tool args use PascalCase (write_to_file / + // replace_file_content carry TargetFile — verified from the agy binary). + toolInput.TargetFile; + return typeof cand === "string" && cand.length > 0 ? [cand] : []; +} + +/** Extract Add/Update file paths from an apply_patch body (deletes ignored). */ +export function extractPatchPaths(patch: string): string[] { + const paths: string[] = []; + for (const line of patch.split(/\r?\n/)) { + const m = line.match(/^\*{3} (?:Add|Update) File: (.+)$/); + if (m?.[1]) paths.push(m[1].trim()); + } + return paths; +} + +// ── Pure handler (canonical ABI) ───────────────────────────── + +/** + * post_tool → record touched code files (always returns null). + * stop → block the stop while a recorded file is over budget + * (null once everything fits or MAX_STOP_BLOCKS is exhausted). + */ +export async function run( + input: HookInput, + ctx: HandlerCtx, +): Promise { + if (input.kind === "post_tool") return recordTouched(input, ctx); + if (input.kind === "stop") return enforceOnStop(input, ctx); + return null; +} + +function recordTouched( + input: HookInput & { kind: "post_tool" }, + ctx: HandlerCtx, +): null { + const { toolName, toolInput } = input; + // Config and state live at the resolved project root; a relative tool path + // is relative to the session's working directory (the payload cwd). + const projectDir = ctx.cwd || input.cwd; + if (!projectDir) return null; + const sessionCwd = input.cwd || projectDir; + if (!EDIT_TOOLS.has(toolName.toLowerCase())) return null; + + // Opt-in gate — with the default (disabled) config nothing below runs. + const config = loadGuardConfig(projectDir, ctx.config); + if (!config.enabled) return null; + + const sid = ctx.sid ?? "unknown"; + let state: GuardState | null = null; + for (const rawPath of resolveEditedPaths(toolName, toolInput)) { + const absPath = isAbsolute(rawPath) + ? rawPath + : resolve(sessionCwd, rawPath); + const relPath = toPosixPath(relative(projectDir, absPath)); + if (!isRefactorableFile(relPath)) continue; + if (!existsSync(absPath)) continue; + + let lineCount: number; + try { + lineCount = countLines(readFileSync(absPath, "utf-8")); + } catch { + continue; + } + state ??= readState(projectDir, sid); + state.touched[relPath] = { lines: lineCount, ts: new Date().toISOString() }; + } + if (state) { + try { + atomicWriteJson(statePath(projectDir, sid), state); + } catch { + // Recorder is best-effort; never fail the tool result. + } + } + return null; +} + +function enforceOnStop( + input: HookInput & { kind: "stop" }, + ctx: HandlerCtx, +): HandlerResult | null { + const projectDir = ctx.cwd || input.cwd; + if (!projectDir) return null; + + const config = loadGuardConfig(projectDir, ctx.config); + if (!config.enabled) return null; + + const sid = ctx.sid ?? "unknown"; + const state = readState(projectDir, sid); + // Cursor's documented stop payload carries no session id while its + // afterFileEdit payload may — merge the "unknown" bucket so a recorder/ + // enforcer sid mismatch cannot silently skip enforcement. The merge is + // PER FILE and only for files the sid bucket does not know: pulling + // unknown's stop-block counts wholesale would let one session's exhausted + // budget suppress enforcement for every later session in the project. + const overflow = sid !== "unknown" ? readState(projectDir, "unknown") : null; + const overflowFiles = new Set(); + if (overflow) { + for (const [k, v] of Object.entries(overflow.touched)) { + if (k in state.touched) continue; + state.touched[k] = v; + state.stopBlocks[k] = overflow.stopBlocks[k] ?? 0; + overflowFiles.add(k); + } + } + const touched = Object.keys(state.touched); + if (touched.length === 0) return null; + + const offenders: Array<{ relPath: string; lines: number }> = []; + for (const relPath of touched) { + const absPath = join(projectDir, relPath); + if (!existsSync(absPath)) continue; + let lineCount: number; + try { + lineCount = countLines(readFileSync(absPath, "utf-8")); + } catch { + continue; + } + // Re-count at stop time — the recorder's snapshot may be stale (the file + // may have been split back under budget later in the turn). + state.touched[relPath] = { lines: lineCount, ts: new Date().toISOString() }; + if (lineCount <= config.maxLines) continue; + if ((state.stopBlocks[relPath] ?? 0) >= MAX_STOP_BLOCKS) continue; + offenders.push({ relPath, lines: lineCount }); + } + + if (offenders.length > 0) { + for (const { relPath } of offenders) { + state.stopBlocks[relPath] = (state.stopBlocks[relPath] ?? 0) + 1; + } + // Non-blocking-stop vendors: the returned block below is ignored by the + // host, so force the refactor out-of-band. Only on each file's FIRST + // block (count just became 1) to avoid duplicate spawns. + if (DIRECT_DISPATCH_VENDORS.has(ctx.vendor)) { + const firstTimers = offenders.filter( + (o) => state.stopBlocks[o.relPath] === 1, + ); + if (firstTimers.length > 0) { + spawnRefactorAgent(projectDir, sid, firstTimers, config.maxLines); + } + } + } + try { + atomicWriteJson(statePath(projectDir, sid), state); + // Reflect updated counts for unknown-origin files back into the unknown + // bucket so a later id-less stop keeps a consistent (bounded) count. + if (overflow && overflowFiles.size > 0) { + for (const k of overflowFiles) { + const touchedEntry = state.touched[k]; + if (touchedEntry) overflow.touched[k] = touchedEntry; + overflow.stopBlocks[k] = state.stopBlocks[k] ?? 0; + } + atomicWriteJson(statePath(projectDir, "unknown"), overflow); + } + } catch { + // State write failure must not swallow the block itself. + } + if (offenders.length === 0) return null; + + const fileList = offenders + .map((o) => `${o.relPath} (${o.lines} lines)`) + .join(", "); + return { + type: "block", + reason: + `[oma refactor-guard] Edited file(s) exceed the ${config.maxLines}-line ` + + `budget: ${fileList}. Before ending this turn, dispatch the ` + + `\`refactor-engineer\` subagent to split each file into smaller, ` + + `cohesive modules (native Agent tool when the runtime supports it, ` + + `otherwise \`oma agent spawn refactor-engineer\`). The refactor must be ` + + `behavior-preserving and land as refactor-only changes. Adjust via ` + + `\`refactor_guard.max_lines\` / \`refactor_guard.enabled\` in ` + + `.agents/oma-config.yaml.`, + }; +} + +/** + * Detached, fire-and-forget cross-vendor dispatch of the refactor agent. + * Resolves `oma` from PATH; silently skips when unavailable (fail-open — the + * hook must never crash the host loop). stdio is ignored so the child cannot + * outlive-block the hook process. + */ +function spawnRefactorAgent( + projectDir: string, + sid: string, + offenders: Array<{ relPath: string; lines: number }>, + maxLines: number, +): void { + try { + const files = offenders + .map((o) => `${o.relPath} (${o.lines} lines)`) + .join(", "); + const prompt = + `Refactor the following file(s) so each is at most ${maxLines} lines, ` + + `splitting them into smaller cohesive modules. The refactor must be ` + + `behavior-preserving and land as refactor-only changes: ${files}`; + const child = spawn( + "oma", + [ + "agent", + "spawn", + "refactor-engineer", + prompt, + sid, + "--workspace", + projectDir, + ], + { detached: true, stdio: "ignore" }, + ); + // ENOENT (oma not on PATH) surfaces as an async 'error' event, not a + // synchronous throw — swallow it or it crashes the hook process. + child.on("error", () => {}); + child.unref(); + } catch { + // Synchronous spawn failure — fail-open. + } +} + +// ── Standalone entry (pi subprocess / direct bun invocation) ── + +interface StandaloneInput { + hook_event_name?: string; + tool_name?: string; + tool_input?: Record; + tool_response?: Record; + session_id?: string; + sessionId?: string; + [key: string]: unknown; +} + +function main() { + const inputFile = process.env.OMA_HOOK_INPUT_FILE; + const raw = inputFile + ? readFileSync(inputFile, "utf-8") + : readFileSync(0, "utf-8"); + if (!raw.trim()) process.exit(0); + + const parsed: StandaloneInput = JSON.parse(raw); + + // agy runs core hooks standalone (no `oma hook run` router): its envelope is + // camelCase with a nested toolCall (verified against the agy 1.1.13 binary). + const toolCall = parsed.toolCall as + | { name?: unknown; args?: unknown } + | undefined; + if (isAgyInput(parsed) || (toolCall && typeof toolCall.name === "string")) { + const vendor: Vendor = "antigravity"; + const projectDir = + agyProjectDir(parsed) || ((parsed.cwd as string | undefined) ?? ""); + const sid = agyConversationId(parsed) ?? undefined; + const isPostTool = typeof toolCall?.name === "string"; + const hookInput: HookInput = isPostTool + ? { + kind: "post_tool", + toolName: toolCall?.name as string, + toolInput: (toolCall?.args as Record) ?? {}, + cwd: projectDir, + } + : { kind: "stop", cwd: projectDir }; + + run(hookInput, { vendor, cwd: projectDir, sid }) + .then((result) => { + // agy PostToolUse contract: print a literal `{}` (recorder is silent). + if (isPostTool) console.log("{}"); + else if (result && result.type === "block") { + console.log(makeBlockOutput(vendor, result.reason)); + } + process.exit(0); + }) + .catch(() => { + if (isPostTool) console.log("{}"); + process.exit(0); + }); + return; + } + + // Standalone path is vendor-agnostic here; claude covers the common dialect. + const vendor: Vendor = "claude"; + const projectDir = getProjectDir(vendor, parsed); + const sid = parsed.session_id ?? parsed.sessionId; + + // Stop payloads carry no tool_name; anything with one is the recorder path. + const hookInput: HookInput = parsed.tool_name + ? { + kind: "post_tool", + toolName: parsed.tool_name, + toolInput: { ...(parsed.tool_input ?? {}) }, + toolResponse: parsed.tool_response, + cwd: projectDir, + } + : { kind: "stop", cwd: projectDir }; + + run(hookInput, { vendor, cwd: projectDir, sid }) + .then((result) => { + if (result && result.type === "block") { + console.log(makeBlockOutput(vendor, result.reason)); + } + process.exit(0); + }) + .catch(() => process.exit(0)); +} + +if (import.meta.main) { + main(); +} diff --git a/.agents/hooks/core/scm-guard.ts b/.agents/hooks/core/scm-guard.ts new file mode 100644 index 0000000..4a767a2 --- /dev/null +++ b/.agents/hooks/core/scm-guard.ts @@ -0,0 +1,289 @@ +// PreToolUse hook — Block staging of likely-secret files (oma-scm enforcement). +// Enforces `forbidden_patterns` from oma-scm's commit-config.yaml mechanically: +// before this hook the pattern list was advisory-only (model compliance). +// Works with: Claude Code, Codex CLI, Cursor, Grok, Kimi, Kiro, Qwen Code. +// +// Scope decisions: +// - Only `git add` segments are guarded. Untracked secrets must pass through +// `git add` to ever reach a commit (`git commit ` / `-a` only touch +// already-tracked files), so staging is the single choke point. +// - Broad staging (`git add -A` / `git add .`) is NOT blocked here: the rule +// ("never without explicit permission") depends on user consent the hook +// cannot observe. It stays an agent-level guardrail. +// - Escape hatch: a command containing `OMA_SCM_ALLOW_SECRETS=1` bypasses the +// guard, mirroring SKILL.md Guardrail 0 (explicit user override — surface +// once, then proceed). + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { makePreToolDenyOutput } from "./hook-output.ts"; +import type { + HandlerCtx, + HandlerResult, + HookConfig, + HookInput, + Vendor, +} from "./types.ts"; +import { getProjectDir } from "./vendor-detect.ts"; + +// --- Defaults (mirror .agents/skills/oma-scm/config/commit-config.yaml) --- +// Used when the config file is absent (global installs without project config, +// or repos that removed the skill). Keep in sync with the yaml SSOT. + +const DEFAULT_FORBIDDEN_PATTERNS = [ + "*.env", + "*.env.*", + "credentials.json", + "secrets.yaml", + "*.pem", + "*.key", + ".env.local", + "*.p12", + "*.pfx", + "id_rsa*", + "id_ed25519*", + ".npmrc", + "service-account*.json", + "*.keystore", + "*.jks", + "*.tfvars", + "*.tfstate", + "*.tfstate.*", + ".netrc", + ".pypirc", +]; + +const DEFAULT_ALLOWED_EXCEPTIONS = ["*.example", "*.sample", "*.template"]; + +const BYPASS_TOKEN = "OMA_SCM_ALLOW_SECRETS=1"; + +const MAIN_CONFIG_RELPATH = join(".agents", "oma-config.yaml"); +const SKILL_CONFIG_RELPATH = join( + ".agents", + "skills", + "oma-scm", + "config", + "commit-config.yaml", +); + +// --- Config loading (regex-based, consistent with core's yaml handling) --- + +/** + * Extract a top-level or indented yaml string-list section (`key:` followed by `- "item"` + * lines) without a yaml dependency — core handlers must stay standalone. + */ +export function extractYamlList(content: string, key: string): string[] | null { + const lines = content.split(/\r?\n/); + const start = lines.findIndex((l) => new RegExp(`^\\s*${key}:\\s*$`).test(l)); + if (start === -1) return null; + const items: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (/^\s*(#|$)/.test(line)) continue; // comments / blanks inside the list + const item = line.match(/^\s+-\s+["']?([^"']+)["']?\s*$/)?.[1]; + if (!item) break; // end of the list block + items.push(item); + } + return items; +} + +interface GuardConfig { + forbidden: string[]; + exceptions: string[]; +} + +function stringList(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const items = value.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + return items.length > 0 ? items : null; +} + +/** `scm.forbidden_patterns` from the config `oma hook run` loaded, if set. */ +function guardConfigFromConfig(config: HookConfig): GuardConfig | null { + const scm = config.scm; + if (!scm || typeof scm !== "object" || Array.isArray(scm)) return null; + const block = scm as Record; + const forbidden = stringList(block.forbidden_patterns); + if (!forbidden) return null; + return { + forbidden, + exceptions: + stringList(block.allowed_exceptions) ?? DEFAULT_ALLOWED_EXCEPTIONS, + }; +} + +function loadGuardConfig(projectDir: string, config?: HookConfig): GuardConfig { + // The dispatcher's config (CUE / local overlay aware) replaces the + // oma-config.yaml read; the skill config and built-in defaults still back it. + const fromConfig = config ? guardConfigFromConfig(config) : null; + if (fromConfig) return fromConfig; + const pathsToTry = [ + ...(config ? [] : [join(projectDir, MAIN_CONFIG_RELPATH)]), + join(projectDir, SKILL_CONFIG_RELPATH), + ]; + + for (const configPath of pathsToTry) { + if (!existsSync(configPath)) continue; + try { + const content = readFileSync(configPath, "utf-8"); + const forbidden = extractYamlList(content, "forbidden_patterns"); + if (forbidden && forbidden.length > 0) { + return { + forbidden, + exceptions: + extractYamlList(content, "allowed_exceptions") ?? + DEFAULT_ALLOWED_EXCEPTIONS, + }; + } + } catch { + // Continue to next path or default + } + } + + return { + forbidden: DEFAULT_FORBIDDEN_PATTERNS, + exceptions: DEFAULT_ALLOWED_EXCEPTIONS, + }; +} + +// --- Glob matching (basename semantics, like .gitignore basename patterns) --- + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, "[^/]*") + .replace(/\?/g, "[^/]"); + return new RegExp(`^${escaped}$`); +} + +function basename(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx === -1 ? trimmed : trimmed.slice(idx + 1); +} + +export function matchesForbidden(path: string, config: GuardConfig): boolean { + const name = basename(path); + if (!name) return false; + if (config.exceptions.some((p) => globToRegExp(p).test(name))) return false; + return config.forbidden.some((p) => globToRegExp(p).test(name)); +} + +// --- Command parsing --- + +/** + * Extract candidate pathspec tokens from every `git add` segment of a shell + * command. Naive quoting-aware-ish tokenizer: segments split on shell control + * operators; option tokens (`-…`) and the `--` separator are skipped. + */ +export function extractGitAddPaths(command: string): string[] { + const paths: string[] = []; + const segments = command.split(/&&|\|\||;|\||\n/); + for (const segment of segments) { + const m = segment.match(/\bgit\s+(?:[-\w]+=\S+\s+)*add\b(.*)$/); + if (!m) continue; + const rest = m[1] ?? ""; + // Tokenize, honoring simple single/double quotes. + const tokens = rest.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + for (const raw of tokens) { + const token = raw.replace(/^["']|["']$/g, ""); + if (token === "--") continue; + if (token.startsWith("-")) continue; // options (incl. -A/--all; see header) + paths.push(token); + } + } + return paths; +} + +// ── Pure handler (canonical ABI) ───────────────────────────── + +/** + * Pure decision function — blocks `git add` of likely-secret files. + * Returns a `block` HandlerResult when a staged path matches + * `forbidden_patterns` (minus `allowed_exceptions`), else `null` (fail-open). + */ +export async function run( + input: HookInput, + ctx: HandlerCtx, +): Promise { + if (input.kind !== "pre_tool") return null; + + const { toolName, toolInput } = input; + // Config resolves at the project root, not the session's current directory. + const projectDir = ctx.cwd || input.cwd; + + 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; + if (!/\bgit\b/.test(command)) return null; + if (command.includes(BYPASS_TOKEN)) return null; + + const candidates = extractGitAddPaths(command); + if (candidates.length === 0) return null; + + const config = loadGuardConfig(projectDir, ctx.config); + const flagged = candidates.filter((p) => matchesForbidden(p, config)); + if (flagged.length === 0) return null; + + return { + type: "block", + reason: + `[oma scm-guard] Blocked: staging likely-secret file(s): ${flagged.join(", ")} ` + + `(matched forbidden_patterns in ${MAIN_CONFIG_RELPATH}). ` + + `Surface this to the user; if they explicitly approve committing these files, ` + + `re-run the command prefixed with ${BYPASS_TOKEN}.`, + }; +} + +// ── Standalone entry (pi subprocess / direct bun invocation) ── + +interface PreToolUseInput { + tool_name: string; + tool_input: { + command?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +function main() { + const inputFile = process.env.OMA_HOOK_INPUT_FILE; + const raw = inputFile + ? readFileSync(inputFile, "utf-8") + : readFileSync(0, "utf-8"); + if (!raw.trim()) process.exit(0); + + const parsed: PreToolUseInput = JSON.parse(raw); + // Standalone path is vendor-agnostic here; claude covers the common dialect. + const vendor: Vendor = "claude"; + const projectDir = getProjectDir(vendor, parsed); + + const hookInput: HookInput = { + kind: "pre_tool", + toolName: parsed.tool_name, + toolInput: { ...(parsed.tool_input ?? {}) }, + cwd: projectDir, + }; + + run(hookInput, { vendor, cwd: projectDir }) + .then((result) => { + if (result && result.type === "block") { + console.log(makePreToolDenyOutput(vendor, result.reason)); + } + process.exit(0); + }) + .catch(() => process.exit(0)); +} + +if (import.meta.main) { + main(); +} diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts index d4f321d..8e42660 100644 --- a/.agents/hooks/core/serena-primer.ts +++ b/.agents/hooks/core/serena-primer.ts @@ -1,274 +1,14 @@ #!/usr/bin/env bun /** - * oh-my-agent — Serena Primer Hook (prompt kind) + * oh-my-agent — Serena Primer Hook (backwards compatibility wrapper) * - * Works with: Claude Code, Codex CLI, Cursor, Gemini CLI, Qwen Code, - * Antigravity, Grok, Kiro. - * - * Serena ships per-vendor context prompts that say "prefer Serena's symbolic - * tools over plain grep/Read", but that guidance only reaches the model when - * the runtime actually surfaces it — and some runtimes (notably Claude Code) - * DEFER MCP tools, so the model never sees Serena's tools and silently falls - * back to grep/Read. This handler injects a short, vendor-neutral reminder - * ONCE per session so Serena's code-intelligence tools are loaded and preferred. - * - * Gating: - * - Only fires when the project is Serena-activated (`.serena/project.yml` - * exists) — i.e. Serena is actually configured for this repo. - * - Only fires once per session (state file under `.agents/state/`). - * - * Runs on the vendor's prompt event (UserPromptSubmit / BeforeAgent / - * PreInvocation / beforeSubmitPrompt / userPromptSubmit), after skill-injector. - */ - -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 { makePromptOutput } from "./hook-output.ts"; -import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; - -const SESSION_TTL_MS = 60 * 60 * 1000; - -// ── Serena Detection ────────────────────────────────────────── - -/** - * True when Serena has been activated for this project. Serena writes - * `.serena/project.yml` on first activation, so its presence is a robust, - * vendor-agnostic signal that Serena is in use here. - */ -export function isSerenaProject(projectDir: string): boolean { - return existsSync(join(projectDir, ".serena", "project.yml")); -} - -// ── Session-once State ──────────────────────────────────────── - -interface PrimerState { - sessions: Record; -} - -function getStatePath(projectDir: string): string { - return join(projectDir, ".agents", "state", "serena-primer.json"); -} - -function readState(projectDir: string): PrimerState { - const p = getStatePath(projectDir); - if (!existsSync(p)) return { sessions: {} }; - try { - const parsed = JSON.parse(readFileSync(p, "utf-8")); - if (parsed && typeof parsed === "object" && parsed.sessions) { - return parsed as PrimerState; - } - } catch { - // corrupted — reset - } - return { sessions: {} }; -} - -function writeState(projectDir: string, state: PrimerState): void { - const p = getStatePath(projectDir); - try { - mkdirSync(dirname(p), { recursive: true }); - writeFileSync(p, JSON.stringify(state, null, 2)); - } catch { - // failing open is acceptable — worst case the primer injects again - } -} - -/** - * Returns true and records the session when this is the first prompt of the - * session (within TTL); returns false on subsequent prompts. Expired sessions - * are pruned. Pure given `now` for testability. - */ -export function claimSession( - projectDir: string, - sessionId: string, - now: number = Date.now(), -): boolean { - const state = readState(projectDir); - - for (const [id, ts] of Object.entries(state.sessions)) { - if (now - ts > SESSION_TTL_MS) delete state.sessions[id]; - } - - const last = state.sessions[sessionId]; - if (last !== undefined && now - last <= SESSION_TTL_MS) { - return false; - } - - state.sessions[sessionId] = now; - writeState(projectDir, state); - return true; -} - -// ── Primer Content ──────────────────────────────────────────── - -/** - * Vendor-neutral Serena priming context. Kept short — it is injected once per - * session as advisory guidance, not a per-turn reminder. + * Delegates to code-intelligence-primer.ts. */ -export function primerContext(): string { - return [ - "[OMA SERENA PRIMER]", - "This project is Serena-activated. Prefer Serena's symbol-aware code-intelligence tools over plain grep/Read for code work.", - "", - "- If Serena's tools are not yet visible (some runtimes defer MCP tools), load them first, then call `initial_instructions` once to read Serena's manual — unless your runtime context already injected it.", - "- 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.", - ].join("\n"); -} - -// ── Pure handler (canonical ABI) ───────────────────────────── - -/** - * Pure decision function — injects the Serena primer on the first prompt of a - * Serena-activated project's session, else returns null. - * `ctx.cwd` must be the resolved git-root project directory. - */ -export async function run( - input: HookInput, - ctx: HandlerCtx, -): Promise { - if (input.kind !== "prompt") return null; - - const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; - - if (!isSerenaProject(projectDir)) return null; - if (!claimSession(projectDir, sessionId)) return null; - - return { type: "context", additionalContext: primerContext() }; -} -// ── Standalone entry (pi subprocess / direct bun invocation) ── +export * from "./code-intelligence-primer.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"; - if (path.includes(`${join(".pi", "extensions")}`)) return "pi"; - return null; -} - -function detectVendor(input: Record): Vendor { - const byScriptPath = inferVendorFromScriptPath(); - if (byScriptPath) return byScriptPath; - if (isAgyInput(input)) return "antigravity"; - const event = input.hook_event_name as string | undefined; - const hookEventName = input.hookEventName as string | undefined; - if (process.env.GROK_WORKSPACE_ROOT) return "grok"; - if ( - process.env.KIRO_PROJECT_DIR || - event === "userPromptSubmit" || - hookEventName === "userPromptSubmit" - ) { - return "kiro"; - } - if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; - if (event === "beforeSubmitPrompt") return "cursor"; - if ( - event === "UserPromptSubmit" && - "session_id" in input && - !("sessionId" in input) - ) - return "codex"; - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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" - ); -} - -async function main() { - const raw = readFileSync(0, "utf-8"); - let input: Record; - try { - input = JSON.parse(raw); - } catch { - process.exit(0); - } - - const vendor = detectVendor(input); - const projectDir = getProjectDir(vendor, input); - const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; - - // agy's PreInvocation stdin carries no `prompt`; recover it and only act on - // the first invocation of a turn. - if (vendor === "antigravity" && !prompt) { - const invocationNum = input.invocationNum; - if (typeof invocationNum === "number" && invocationNum > 1) process.exit(0); - prompt = readAgyPrompt(input.transcriptPath); - } - - const hookInput: HookInput = { kind: "prompt", prompt, cwd: projectDir }; - const ctx: HandlerCtx = { vendor, cwd: projectDir, sid: sessionId }; - - const result = await run(hookInput, ctx); - if (result && result.type === "context") { - process.stdout.write(makePromptOutput(vendor, result.additionalContext)); - } - process.exit(0); -} +import { runStandAlone } from "./code-intelligence-primer.ts"; if (import.meta.main) { - main().catch(() => process.exit(0)); + runStandAlone().catch(() => process.exit(0)); } diff --git a/.agents/hooks/core/session-storage.ts b/.agents/hooks/core/session-storage.ts new file mode 100644 index 0000000..dec6ca9 --- /dev/null +++ b/.agents/hooks/core/session-storage.ts @@ -0,0 +1,235 @@ +// Dependency-free storage paths shared by installed hooks and the CLI. +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + existsSync, + linkSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +export const STATE_ROOT = ".agents/state/sessions"; + +export interface LocalProfile { + schemaVersion: 1; + slot: string; + profileId: string; + createdAt: string; + // Future login links a stable server subject; no credentials or Git identity. + account: { issuer: string; subject: string } | null; +} + +export interface SessionContext { + schemaVersion: 1; + projectId: string; + projectDir: string; + profile: string; +} + +export function profileSlot(): string { + const slot = process.env.OMA_PROFILE ?? "0"; + if (!/^(0|[1-9][0-9]{0,9})$/.test(slot)) { + throw new Error("OMA_PROFILE must be a non-negative profile number"); + } + return slot; +} + +export function profileDir(): string { + const root = process.env.OMA_STATE_HOME ?? join(homedir(), ".oma"); + if (!isAbsolute(root)) throw new Error("OMA_STATE_HOME must be absolute"); + return join(root, "u", profileSlot()); +} + +export function projectIdentity(projectDir: string): SessionContext { + let ancestor = resolve(projectDir); + const missing: string[] = []; + let canonical = ancestor; + while (true) { + try { + canonical = join(realpathSync(ancestor), ...missing); + break; + } catch { + // Resolve existing ancestors too (e.g. macOS /var -> /private/var), + // keeping the same identity after a worktree directory is removed. + const parent = dirname(ancestor); + if (parent === ancestor) break; + missing.unshift(basename(ancestor)); + ancestor = parent; + } + } + return { + schemaVersion: 1, + projectId: createHash("sha256").update(canonical).digest("hex"), + projectDir: canonical, + profile: profileSlot(), + }; +} + +export function projectStateDir(projectDir: string): string { + return join(profileDir(), "projects", projectIdentity(projectDir).projectId); +} + +export function legacySessionsDir(projectDir: string): string { + return join(projectDir, STATE_ROOT); +} + +export function sessionsDir(_projectDir?: string): string { + return join(profileDir(), "sessions"); +} + +export function indexPath(projectDir: string): string { + return join(projectStateDir(projectDir), "_index.json"); +} + +export function sessionArchiveRoot(projectDir: string): string { + return join(projectStateDir(projectDir), "archive"); +} + +export function sessionArchiveRoots(projectDir: string): string[] { + return [ + sessionArchiveRoot(projectDir), + ...(profileSlot() === "0" + ? [join(projectDir, ".agents", "state", "archive")] + : []), + ]; +} + +export function readableIndexPath(projectDir: string): string { + const current = indexPath(projectDir); + if (existsSync(current) || profileSlot() !== "0") return current; + const legacy = join(legacySessionsDir(projectDir), "_index.json"); + return existsSync(legacy) ? legacy : current; +} + +export function isValidSid(sid: string): boolean { + return ( + sid.length > 0 && + sid.length <= 128 && + !sid.includes("..") && + /^[A-Za-z0-9._-]+$/.test(sid) + ); +} + +function assertSid(sid: string): void { + if (!isValidSid(sid)) throw new Error(`Invalid session id: ${sid}`); +} + +export function createSessionId(now = new Date()): string { + return `${now.toISOString().slice(0, 10)}_${randomBytes(12).toString("base64url")}`; +} + +function readContext(dir: string): SessionContext | null { + try { + return JSON.parse(readFileSync(join(dir, "context.json"), "utf-8")); + } catch { + return null; + } +} + +export function sessionDir(projectDir: string, sid: string): string { + assertSid(sid); + // Existing local sessions remain writable in place, only in profile 0. + const legacy = join(legacySessionsDir(projectDir), sid); + if (profileSlot() === "0" && existsSync(legacy)) return legacy; + const current = join(sessionsDir(), sid); + const context = readContext(current); + if (!context && existsSync(join(current, "events.jsonl"))) { + throw new Error(`Session ${sid} has missing or invalid project ownership`); + } + if ( + context && + (context.projectId !== projectIdentity(projectDir).projectId || + context.profile !== profileSlot()) + ) { + throw new Error(`Session ${sid} belongs to another project`); + } + return current; +} + +// Publish fully-written immutable JSON without replacing another writer's file. +function publishJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + try { + linkSync(tmp, path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + } finally { + if (existsSync(tmp)) unlinkSync(tmp); + } +} + +export function ensureProfile(): LocalProfile { + const path = join(profileDir(), "profile.json"); + if (!existsSync(path)) { + publishJson(path, { + schemaVersion: 1, + slot: profileSlot(), + profileId: randomUUID(), + createdAt: new Date().toISOString(), + account: null, + } satisfies LocalProfile); + } + const profile = JSON.parse(readFileSync(path, "utf-8")) as LocalProfile; + if ( + profile.schemaVersion !== 1 || + profile.slot !== profileSlot() || + typeof profile.profileId !== "string" || + !profile.profileId + ) { + throw new Error(`Invalid local profile: ${path}`); + } + return profile; +} + +export function ensureSessionStorage(projectDir: string, sid: string): void { + const dir = sessionDir(projectDir, sid); + if (dir === join(legacySessionsDir(projectDir), sid)) return; + ensureProfile(); + const identity = projectIdentity(projectDir); + if (!existsSync(join(dir, "context.json"))) { + publishJson(join(dir, "context.json"), identity); + } + const context = readContext(dir); + if ( + context?.projectId !== identity.projectId || + context.profile !== identity.profile + ) { + throw new Error( + `Session ${sid} has invalid or conflicting project ownership`, + ); + } +} + +export function listSessionIds(projectDir: string): string[] { + const ids = new Set(); + const current = sessionsDir(); + const identity = projectIdentity(projectDir); + if (existsSync(current)) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (!entry.isDirectory() || !isValidSid(entry.name)) continue; + const context = readContext(join(current, entry.name)); + if ( + context?.projectId === identity.projectId && + context.profile === identity.profile + ) { + ids.add(entry.name); + } + } + } + const legacy = legacySessionsDir(projectDir); + if (profileSlot() === "0" && existsSync(legacy)) { + for (const entry of readdirSync(legacy, { withFileTypes: true })) { + if (entry.isDirectory() && isValidSid(entry.name)) ids.add(entry.name); + } + } + return [...ids]; +} diff --git a/.agents/hooks/core/session-summary.ts b/.agents/hooks/core/session-summary.ts new file mode 100644 index 0000000..0b0d4a8 --- /dev/null +++ b/.agents/hooks/core/session-summary.ts @@ -0,0 +1,192 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + deriveMeta, + emitEvent, + type OmaEvent, + readEvents, + type SessionMeta, +} from "./state-core.ts"; +import { renderEventPayload, snapshotText } from "./vendor-renderer.ts"; + +export interface SessionSummaryWriter { + write(name: string, content: string): Promise | boolean; +} + +export type SessionSummaryExportMethod = + | "external-writer" + | "direct-fs" + | "none"; + +export interface SessionSummaryExportResult { + sid: string; + workflow: string; + summaryName: string; + path: string; + method: SessionSummaryExportMethod; + written: boolean; + warning?: string; +} + +function sanitizeSegment(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || "session"; +} + +export function sessionSummaryName(workflow: string, sid: string): string { + return `session-${sanitizeSegment(workflow)}-${sanitizeSegment(sid)}`; +} + +export function getSessionSummaryPath( + projectDir: string, + workflow: string, + sid: string, +): string { + return join( + projectDir, + ".agents", + "state", + "memories", + `${sessionSummaryName(workflow, sid)}.md`, + ); +} + +function payloadText( + event: OmaEvent, + key: string, + fallback = "", + limit = 160, +): string { + return snapshotText(event.payload?.[key], limit) || fallback; +} + +export function buildSessionSummary( + sid: string, + meta: SessionMeta, + events: OmaEvent[], +): string { + const decisions = events + .filter((event) => event.kind === "decision.made") + .slice(-8); + const gates = events + .filter( + (event) => event.kind === "gate.passed" || event.kind === "gate.failed", + ) + .slice(-12); + const blockers = events + .filter((event) => event.kind === "blocker.raised") + .slice(-8); + const endings = events + .filter((event) => event.kind === "session.ended") + .slice(-8); + const boundaries = events + .filter((event) => event.kind === "boundary") + .slice(-8); + const workflow = snapshotText(meta.workflow) || "(unknown)"; + const lines = [ + `# OMA Session Summary: ${workflow} ${snapshotText(sid)}`, + "", + `- workflow: ${workflow}`, + `- status: ${snapshotText(meta.status)}`, + `- phase: ${snapshotText(meta.currentPhase) || "(none)"}`, + `- created: ${snapshotText(meta.createdAt) || "(unknown)"}`, + `- events: ${events.length}`, + "", + "## Decisions", + ]; + if (!decisions.length) lines.push("- (none recorded)"); + for (const event of decisions) { + const subject = payloadText(event, "subject", "(unspecified)"); + const decision = payloadText(event, "decision", "(unspecified)", 240); + const rationale = payloadText(event, "rationale"); + const instanceId = payloadText(event, "instanceId", "", 96); + lines.push( + `- **${subject}** → ${decision}${rationale ? ` _(${rationale})_` : ""}${instanceId ? ` [instance: ${instanceId}]` : ""}`, + ); + } + lines.push("", "## Gates"); + if (!gates.length) lines.push("- (none recorded)"); + for (const event of gates) { + const gate = payloadText(event, "gate", "(unnamed)"); + const by = payloadText(event, "by") || payloadText(event, "reviewer"); + const outcome = event.kind === "gate.passed" ? "passed" : "failed"; + const reason = payloadText(event, "reason"); + const summary = payloadText(event, "summary"); + lines.push( + `- ${gate}${by ? ` by ${by}` : ""}: ${outcome}${reason ? ` (${reason})` : ""}${summary ? ` — ${summary}` : ""} (${snapshotText(event.ts, 32)})`, + ); + } + lines.push("", "## Blockers"); + if (!blockers.length) lines.push("- (none recorded)"); + for (const event of blockers) lines.push(`- ${renderEventPayload(event)}`); + lines.push("", "## Session Endings"); + if (!endings.length) lines.push("- (none recorded)"); + for (const event of endings) lines.push(`- ${renderEventPayload(event)}`); + if (boundaries.length) { + lines.push("", "## Vendor Boundaries"); + for (const event of boundaries) + lines.push( + `- ${payloadText(event, "fromVendor", "(new)")} → ${payloadText(event, "toVendor", snapshotText(event.vendor) || "(unknown)")} (${snapshotText(event.ts, 32)})`, + ); + } + lines.push("", "## Recent Events"); + for (const event of events.slice(-12)) { + const details = renderEventPayload(event); + lines.push( + `- ${snapshotText(event.ts, 32)} \`${snapshotText(event.kind, 72)}\`${details ? ` | ${details}` : ""}`, + ); + } + return `${lines.join("\n").slice(0, 24_000)}\n`; +} + +export async function exportSessionSummary(args: { + sid: string; + projectDir: string; + writer?: SessionSummaryWriter; +}): Promise { + const events = readEvents(args.projectDir, args.sid); + const meta = deriveMeta(args.sid, events); + const workflow = meta.workflow || "session"; + const summaryName = sessionSummaryName(workflow, args.sid); + const path = getSessionSummaryPath(args.projectDir, workflow, args.sid); + const content = buildSessionSummary(args.sid, meta, events); + const base = { sid: args.sid, workflow, summaryName, path }; + if (args.writer) { + try { + if (await args.writer.write(summaryName, content)) + return { ...base, method: "external-writer", written: true }; + } catch { + // An optional external writer can fall back to the coordination store. + } + } + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf-8"); + return { ...base, method: "direct-fs", written: true }; + } catch (error) { + const warning = error instanceof Error ? error.message : String(error); + try { + emitEvent(args.projectDir, args.sid, { + kind: "mirror.warning", + payload: { memoryName: summaryName, summaryName, warning }, + }); + } catch { + process.stderr.write( + `[oma] Session summary export failed and warning event could not be written: ${warning}\n`, + ); + } + return { ...base, method: "none", written: false, warning }; + } +} + +export function renderSessionSummaryResult( + result: SessionSummaryExportResult, +): string { + if (result.written) + return `Exported ${result.sid} → ${result.summaryName} (${result.method})\n ${result.path}`; + return `Session summary skipped for ${result.sid}: ${result.warning ?? "unknown error"}`; +} diff --git a/.agents/hooks/core/skill-injector.ts b/.agents/hooks/core/skill-injector.ts index 4a2ab5a..42b4982 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,112 +21,21 @@ 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, 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 type { HandlerCtx, HandlerResult, HookInput } from "./types.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; const MAX_SKILLS = 3; const SESSION_TTL_MS = 60 * 60 * 1000; -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(); - if (byScriptPath) return byScriptPath; - - // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. - if (isAgyInput(input)) return "antigravity"; - - if (process.env.GROK_WORKSPACE_ROOT || hookEventName?.includes("prompt")) { - if (process.env.GROK_WORKSPACE_ROOT) return "grok"; - } - - if ( - process.env.KIRO_PROJECT_DIR || - event === "userPromptSubmit" || - hookEventName === "userPromptSubmit" - ) { - return "kiro"; - } - - 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"; - } - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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) || @@ -140,32 +49,13 @@ function getSessionId(input: Record): string { interface SkillsTriggerConfig { skills?: Record }>; - cjkScripts?: string[]; } /** - * Load the skills-trigger config from the embedded (bundler-inlined / - * sibling-resolved) triggers.json. Returns {} on any shape error. + * The embedded (bundler-inlined / sibling-resolved) triggers.json. Read only, + * so it is shared instead of deep-cloned on every prompt. */ -function loadTriggersConfig(): SkillsTriggerConfig { - try { - return structuredClone(embeddedTriggers) as SkillsTriggerConfig; - } catch { - return {}; - } -} - -function detectLanguage(projectDir: string): string { - const prefsPath = join(projectDir, ".agents", "oma-config.yaml"); - if (!existsSync(prefsPath)) return "en"; - try { - const content = readFileSync(prefsPath, "utf-8"); - const match = content.match(/^language:\s*(\S+)/m); - return match?.[1] ?? "en"; - } catch { - return "en"; - } -} +const TRIGGERS = embeddedTriggers as unknown as SkillsTriggerConfig; // ── Pattern Building ────────────────────────────────────────── @@ -173,14 +63,16 @@ export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function buildTriggerPatterns( - triggers: string[], - lang: string, - cjkScripts: string[], -): RegExp[] { +/** + * Boundaries depend only on the trigger itself (same rule as + * keyword-detector): ASCII triggers need word boundaries, triggers containing + * non-ASCII text match as substrings. The configured response language must + * not strip boundaries — `language: ko` used to make "work" match "network". + */ +export function buildTriggerPatterns(triggers: string[]): RegExp[] { return triggers.map((kw) => { const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); - if (cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw)) { + if (/[^\p{ASCII}]/u.test(kw)) { return new RegExp(escaped, "i"); } return new RegExp(`\\b${escaped}\\b`, "i"); @@ -235,36 +127,73 @@ export interface SkillMatch { matchedTriggers: string[]; } -export function matchSkills( - prompt: string, - lang: string, - skills: SkillEntry[], - config: SkillsTriggerConfig, -): SkillMatch[] { - const cjkScripts = config.cjkScripts ?? DEFAULT_CJK_SCRIPTS; - const matches: SkillMatch[] = []; +interface CompiledSkillTriggers { + triggers: string[]; + patterns: RegExp[]; +} - for (const skill of skills) { - const jsonEntry = config.skills?.[skill.name]; - if (!jsonEntry) continue; +// Compiled per config object and skill name. The embedded config is a single +// shared object, so every prompt after the first reuses the compiled regexes +// (none use the `g` flag, so test() is stateless). +const compiledSkillCache = new WeakMap< + SkillsTriggerConfig, + Map +>(); +function compiledTriggersFor( + config: SkillsTriggerConfig, + skillName: string, +): CompiledSkillTriggers | null { + let perConfig = compiledSkillCache.get(config); + if (!perConfig) { + perConfig = new Map(); + compiledSkillCache.set(config, perConfig); + } + if (perConfig.has(skillName)) return perConfig.get(skillName) ?? null; + + const jsonEntry = config.skills?.[skillName]; + let compiled: CompiledSkillTriggers | null = null; + if (jsonEntry) { + // All languages merged, never gated by config language: users prompt in + // whichever language they think in (`language` controls the RESPONSE + // language). A keyword written in language X can only match a prompt + // containing X-script text, so merging cannot fire on unrelated prompts. const jsonTriggers = [ ...(jsonEntry.keywords["*"] ?? []), ...(jsonEntry.keywords.en ?? []), - ...(lang !== "en" ? (jsonEntry.keywords[lang] ?? []) : []), + ...Object.entries(jsonEntry.keywords) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), ]; const seen = new Set(); - const allTriggers: string[] = []; + const triggers: string[] = []; for (const t of jsonTriggers) { const key = t.toLowerCase(); if (seen.has(key)) continue; seen.add(key); - allTriggers.push(t); + triggers.push(t); + } + if (triggers.length > 0) { + compiled = { triggers, patterns: buildTriggerPatterns(triggers) }; } - if (allTriggers.length === 0) continue; + } + perConfig.set(skillName, compiled); + return compiled; +} - const patterns = buildTriggerPatterns(allTriggers, lang, cjkScripts); +export function matchSkills( + prompt: string, + skills: SkillEntry[], + config: SkillsTriggerConfig, +): SkillMatch[] { + const matches: SkillMatch[] = []; + + for (const skill of skills) { + const compiled = compiledTriggersFor(config, skill.name); + if (!compiled) continue; + + const { triggers: allTriggers, patterns } = compiled; const matched: string[] = []; let score = 0; @@ -466,14 +395,12 @@ export function formatClaudeSlashSkillContext( entry: ClaudeSlashSkillEntry, ): string { return [ - `[OMA CLAUDE SLASH SKILL INVOKED: ${entry.name}]`, - `User explicitly typed /${entry.name}. Claude Code deprecated \`.claude/commands/\`, so this slash-only workflow lives in SKILL.md with \`disable-model-invocation: true\` — it is NOT in the available-skills list and is NOT callable via the Skill tool.`, - "", - `Honor the user's explicit invocation by reading \`${entry.skillRelPath}\` and following its instructions:`, + `[OMA SLASH SKILL INVOKED: ${entry.name}]`, + `The user explicitly invoked /${entry.name}. Follow the skill content below.`, + `Source: ${entry.skillRelPath}`, + "Read referenced workflow / resource files as needed.", "", entry.body, - "", - "Read any referenced workflow / resource files and proceed step by step. Do NOT respond that the skill is unavailable.", ].join("\n"); } @@ -496,7 +423,6 @@ export function formatContext(matches: SkillMatch[]): string { ]; for (const m of matches) { lines.push(`- **${m.name}** — \`${m.relPath}\``); - lines.push(` Matched triggers: ${m.matchedTriggers.join(", ")}`); } lines.push(""); lines.push( @@ -524,6 +450,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") { @@ -542,12 +473,10 @@ export async function run( if (startsWithSlashCommand(prompt)) return null; if (isPersistentWorkflowActive(projectDir, sessionId)) return null; - const lang = detectLanguage(projectDir); - const config = loadTriggersConfig(); const cleaned = stripCodeBlocks(prompt); const skills = discoverSkills(projectDir); - const matches = matchSkills(cleaned, lang, skills, config); + const matches = matchSkills(cleaned, skills, TRIGGERS); if (matches.length === 0) return null; const { fresh, nextState } = filterFreshMatches( @@ -572,10 +501,10 @@ async function main() { process.exit(0); } - const vendor = detectVendor(input); + const vendor = detectVendorFromInput(input, "prompt", import.meta.filename); 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..52c18f0 100644 --- a/.agents/hooks/core/state-boundary.ts +++ b/.agents/hooks/core/state-boundary.ts @@ -1,105 +1,23 @@ #!/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 { syncGrokContext } from "./grok-context.ts"; +import { agyConversationId } from "./agy-input.ts"; +import { evolutionNoticeLines } from "./evolution-notice.ts"; import { makePromptOutput } from "./hook-output.ts"; import { writeInjectLog } from "./inject-log.ts"; -import { emitEvent, type OmaEvent, readEvents } from "./state-emit.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; +import { + emitEvent, + type OmaEvent, + readEvents, + vendorHomePayload, +} from "./state-emit.ts"; import { getActiveSid, readIndex, setLastSession } from "./state-marker.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; +import { detectVendorFromInput, getProjectDir } 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(); - if (byScriptPath) return byScriptPath; - - // agy (Antigravity) sends no hook_event_name; detect by its stdin shape. - if (isAgyInput(input)) return "antigravity"; - - if (process.env.GROK_WORKSPACE_ROOT || hookEventName?.includes("prompt")) { - if (process.env.GROK_WORKSPACE_ROOT) return "grok"; - } - - if ( - process.env.KIRO_PROJECT_DIR || - event === "userPromptSubmit" || - hookEventName === "userPromptSubmit" - ) { - return "kiro"; - } - - if (event === "PreInvocation") return "antigravity"; - if (event === "BeforeAgent") return "gemini"; - if (event === "beforeSubmitPrompt") return "cursor"; - if ( - event === "UserPromptSubmit" && - "session_id" in input && - !("sessionId" in input) - ) { - return "codex"; - } - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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 +70,7 @@ export async function onBoundary( vendor: Vendor, vendorSid: string, promptText?: string, + forced = false, ): Promise { const idx = readIndex(projectDir); const previous = idx.lastSession; @@ -159,7 +78,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 +97,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", @@ -185,6 +108,7 @@ export async function onBoundary( toVendor: vendor, toVendorSid: vendorSid, previousSid: sid, + ...vendorHomePayload(vendor), }, }); setLastSession(projectDir, vendor, vendorSid); @@ -195,14 +119,21 @@ export async function onBoundary( // out, so the snapshot degrades to local L1 events only (design D33/D34). const recallQuery = buildRecallQuery(projectDir, recentEvents, promptText); const facts: MemoryFact[] = recallQuery - ? await recallFacts(recallQuery, 5) + ? await recallFacts(recallQuery, 5, projectDir) : []; + let evolution: string[] = []; + try { + evolution = evolutionNoticeLines(projectDir); + } catch { + // The notice is a courtesy; a damaged lineage log must not break the hook. + } const rendered = renderStateSnapshot({ vendor, sid, reason: "vendor/session boundary", recentEvents, facts, + evolution, }); // D52: forensic inject audit trail (best-effort, redacted, user-only perms). @@ -217,11 +148,6 @@ export async function onBoundary( rendered, }); - // Grok ignores prompt-hook stdout, so mirror the snapshot to its session-start - // context file (CLAUDE.local.md). Loaded on the next Grok session = close-reopen - // resume on Grok. Best-effort; L1 events remain the SSOT. - if (vendor === "grok") syncGrokContext(projectDir, rendered); - return rendered; } @@ -243,12 +169,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 }; @@ -265,14 +194,14 @@ async function main() { process.exit(0); } - const vendor = detectVendor(input); + const vendor = detectVendorFromInput(input, "prompt", import.meta.filename); const projectDir = getProjectDir(vendor, input); const vendorSid = getVendorSid(input); // 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/state-core.ts b/.agents/hooks/core/state-core.ts new file mode 100644 index 0000000..7d2bbe6 --- /dev/null +++ b/.agents/hooks/core/state-core.ts @@ -0,0 +1,427 @@ +// Canonical state storage/reducer shared by the CLI and standalone hooks. +import { + appendFileSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { validateEventPayload } from "./event-contract.ts"; +import { + ensureProfile, + ensureSessionStorage, + indexPath, + projectIdentity, + readableIndexPath, + sessionDir, +} from "./session-storage.ts"; +import { + withSessionWriteLock, + withStateIndexLock, +} from "./state-index-lock.ts"; + +export * from "./session-storage.ts"; + +export const RETRY_ROOT = ".agents/state/retry"; + +export const INDEX_SCHEMA_VERSION = 1; + +export const SEMANTIC_EVENT_KINDS = new Set([ + "workflow.phase", + "gate.passed", + "gate.failed", + "blocker.raised", + "session.ended", + "decision.made", + "decision.missing", + "skill.evolution.started", + "skill.rollout.recorded", + "skill.pattern.consolidated", + "skill.proposal.created", + "skill.proposal.gated", + "skill.evolution.completed", +]); + +export type EventKind = + | "boundary" + | "session.created" + | "workflow.phase" + | "gate.passed" + | "gate.failed" + | "blocker.raised" + | "decision.made" + | "decision.missing" + | "skill.evolution.started" + | "skill.rollout.recorded" + | "skill.pattern.consolidated" + | "skill.proposal.created" + | "skill.proposal.gated" + | "skill.evolution.completed" + | "session.ended"; + +export interface LastSessionMarker { + vendor: string; + vendorSid: string; + ts: string; +} + +export interface StateIndex { + schemaVersion: 1; + lastSession?: LastSessionMarker; + active: Record; +} + +export interface OmaEvent { + eventId: string; + ts: string; + sid: string; + kind: EventKind | string; + writerPid: number; + vendor?: string; + vendorSid?: string; + parentEventId?: string; + causalityKey?: string; + payload?: Record; +} + +export interface SessionMeta { + sid: string; + schemaVersion: 1; + workflow?: string; + category: string; + status: "active" | "completed" | "failed"; + createdAt?: string; + projectId?: string; + projectDir?: string; + profile?: string; + currentPhase?: string; + gatesPassedBy: Array>; + pendingPeerReviews: Array>; +} + +export function eventsPath(projectDir: string, sid: string): string { + return join(sessionDir(projectDir, sid), "events.jsonl"); +} + +export function metaPath(projectDir: string, sid: string): string { + return join(sessionDir(projectDir, sid), "meta.json"); +} + +export function retryObservePath(projectDir: string): string { + return join(join(projectDir, RETRY_ROOT), "observe.jsonl"); +} + +export function defaultIndex(): StateIndex { + return { schemaVersion: INDEX_SCHEMA_VERSION, active: {} }; +} + +export function createEventId(now = Date.now()): string { + const time = now.toString(36).padStart(10, "0"); + const random = Math.random().toString(36).slice(2, 10).padEnd(8, "0"); + return `${time}${random}`; +} + +export function ensureParent(path: string): void { + mkdirSync(dirname(path), { recursive: true }); +} + +function fsyncParent(path: string): void { + try { + const fd = openSync(dirname(path), "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + } catch { + // Some filesystems do not support directory fsync. + } +} + +export function atomicWriteJson(path: string, value: unknown): void { + ensureParent(path); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + // Open read-write ("r+"): fsyncSync on a read-only handle fails with EPERM + // on Windows, which aborts the metadata write even though the durable + // JSONL event was already appended. See issue #613. + const fd = openSync(tmp, "r+"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmp, path); + fsyncParent(path); +} + +export function readIndex(projectDir: string): StateIndex { + const path = readableIndexPath(projectDir); + if (!existsSync(path)) return defaultIndex(); + try { + const parsed = JSON.parse( + readFileSync(path, "utf-8"), + ) as Partial; + return { + schemaVersion: INDEX_SCHEMA_VERSION, + active: parsed.active ?? {}, + lastSession: parsed.lastSession, + }; + } catch { + return defaultIndex(); + } +} + +export function updateIndex( + projectDir: string, + mutate: (index: StateIndex) => void, + maxRetries = 3, +): StateIndex { + ensureProfile(); + return withStateIndexLock(projectDir, () => { + const path = indexPath(projectDir); + for (let attempt = 0; attempt < maxRetries; attempt++) { + const before = existsSync(path) ? statSync(path).mtimeMs : null; + const next = readIndex(projectDir); + mutate(next); + const after = existsSync(path) ? statSync(path).mtimeMs : null; + if (before !== after) continue; + atomicWriteJson(path, next); + return next; + } + // Retain best-effort compatibility with older, non-locking hook writers. + process.stderr.write( + `[oma] _index.json CAS retries exhausted (${maxRetries}); leaving it stale.\n`, + ); + process.stderr.write( + "[oma] hint: run 'oma state repair' to re-derive active pointers\n", + ); + return readIndex(projectDir); + }); +} + +export function getActiveSid( + index: StateIndex, + category = "main", +): string | null { + return index.active[category] ?? index.active.main ?? null; +} + +export function setActiveSession( + projectDir: string, + category: string, + sid: string, +): StateIndex { + // Validate ownership without creating an empty session for an active pointer. + sessionDir(projectDir, sid); + return updateIndex(projectDir, (index) => { + index.active[category] = sid; + }); +} + +export function setLastSession( + projectDir: string, + vendor: string, + vendorSid: string, +): StateIndex { + return updateIndex(projectDir, (index) => { + index.lastSession = { vendor, vendorSid, ts: new Date().toISOString() }; + }); +} + +export function emitEvent( + projectDir: string, + sid: string, + event: Omit, "sid"> & { kind: string }, +): OmaEvent { + return withSessionWriteLock(projectDir, sid, () => + appendEvent(projectDir, sid, event), + ); +} + +function appendEvent( + projectDir: string, + sid: string, + event: Omit, "sid"> & { kind: string }, +): OmaEvent { + const payloadIssues = validateEventPayload(event.kind, event.payload); + if (payloadIssues.length) { + throw new Error( + `Invalid ${event.kind} event payload: ${payloadIssues.join("; ")}`, + ); + } + const enriched: OmaEvent = { + eventId: event.eventId ?? createEventId(), + ts: event.ts ?? new Date().toISOString(), + sid, + kind: event.kind, + writerPid: event.writerPid ?? process.pid, + vendor: event.vendor, + vendorSid: event.vendorSid, + parentEventId: event.parentEventId, + causalityKey: event.causalityKey, + payload: event.payload, + }; + const path = eventsPath(projectDir, sid); + try { + ensureSessionStorage(projectDir, sid); + ensureParent(path); + appendFileSync(path, `${JSON.stringify(enriched)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`[oma] L1 events.jsonl write failed: ${msg}\n`); + process.stderr.write(`[oma] path=${path}\n`); + process.stderr.write( + "[oma] hint: run 'oma doctor' to diagnose disk/permission/corruption\n", + ); + throw e; + } + if ( + event.kind === "session.created" || + event.kind === "workflow.phase" || + event.kind === "gate.passed" || + event.kind === "gate.failed" || + event.kind === "session.ended" + ) { + refreshMetaUnlocked(projectDir, sid); + } + if (event.kind === "session.ended") { + updateIndex(projectDir, (index) => { + for (const [category, activeSid] of Object.entries(index.active)) { + if (activeSid === sid) delete index.active[category]; + } + }); + } + return enriched; +} + +export function readEvents(projectDir: string, sid: string): OmaEvent[] { + const path = eventsPath(projectDir, sid); + if (!existsSync(path)) return []; + const events: OmaEvent[] = []; + for (const line of readFileSync(path, "utf-8").split("\n")) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as OmaEvent; + if (event.sid && event.kind && event.eventId && event.ts) + events.push(event); + } catch { + // Bad lines are ignored by the library; doctor/state repair can quarantine. + } + } + return sortEvents(events); +} + +export function sortEvents(events: OmaEvent[]): OmaEvent[] { + return [...events].sort((a, b) => { + const ts = a.ts.localeCompare(b.ts); + if (ts !== 0) return ts; + return a.eventId.localeCompare(b.eventId); + }); +} + +/** Read a string field from an event payload, falling back when missing/blank. */ + +export function eventPayloadText( + event: OmaEvent, + key: string, + fallback = "", +): string { + const value = event.payload?.[key]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +export function deriveMeta(sid: string, events: OmaEvent[]): SessionMeta { + const meta: SessionMeta = { + sid, + schemaVersion: INDEX_SCHEMA_VERSION, + category: "main", + status: "active", + gatesPassedBy: [], + pendingPeerReviews: [], + }; + for (const event of sortEvents(events)) { + if (event.kind === "session.created") { + meta.createdAt = meta.createdAt ?? event.ts; + meta.workflow = String(event.payload?.workflow ?? meta.workflow ?? ""); + meta.category = String(event.payload?.category ?? meta.category); + } else if (event.kind === "workflow.phase") { + meta.currentPhase = String(event.payload?.phase ?? ""); + } else if (event.kind === "gate.passed") { + meta.gatesPassedBy.push({ ts: event.ts, ...(event.payload ?? {}) }); + } else if (event.kind === "session.ended") { + const status = event.payload?.status; + if (status === "failed" || status === "completed") meta.status = status; + } + } + return meta; +} + +export function refreshMeta(projectDir: string, sid: string): SessionMeta { + return withSessionWriteLock(projectDir, sid, () => + refreshMetaUnlocked(projectDir, sid), + ); +} + +function refreshMetaUnlocked(projectDir: string, sid: string): SessionMeta { + ensureSessionStorage(projectDir, sid); + const meta = { + ...deriveMeta(sid, readEvents(projectDir, sid)), + ...projectIdentity(projectDir), + }; + atomicWriteJson(metaPath(projectDir, sid), meta); + return meta; +} + +/** + * Home override the vendor process runs under, as an event payload fragment. + * Launchers such as Orca give each account its own home, so a vendor session's + * transcript is only reachable later if the event records where it lives. + */ +export function vendorHomePayload( + vendor: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): { vendorHome?: string } { + const home = + vendor === "codex" + ? env.CODEX_HOME + : vendor === "claude" + ? env.CLAUDE_CONFIG_DIR + : undefined; + const trimmed = home?.trim(); + return trimmed ? { vendorHome: trimmed } : {}; +} + +export function activateWorkflowSession(args: { + projectDir: string; + workflow: string; + category?: string; + sid: string; + vendor?: string; + vendorSid?: string; +}): void { + const category = args.category ?? "main"; + setActiveSession(args.projectDir, category, args.sid); + emitEvent(args.projectDir, args.sid, { + kind: "session.created", + vendor: args.vendor, + vendorSid: args.vendorSid, + payload: { + workflow: args.workflow, + category, + ...vendorHomePayload(args.vendor), + }, + }); +} diff --git a/.agents/hooks/core/state-emit.ts b/.agents/hooks/core/state-emit.ts index d3f2fb6..b7ac184 100644 --- a/.agents/hooks/core/state-emit.ts +++ b/.agents/hooks/core/state-emit.ts @@ -1,167 +1,63 @@ -#!/usr/bin/env bun -import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs"; import { observeWithTimeout } from "./agentmemory-client.ts"; -import { atomicWriteJson, sessionDir } from "./state-marker.ts"; - -const RETRY_FILE = join(".agents", "state", "retry", "observe.jsonl"); -const SEMANTIC_KINDS = new Set([ - "workflow.phase", - "gate.passed", - "gate.failed", - "blocker.raised", - "session.ended", - "decision.made", - "decision.missing", -]); - -export interface OmaEvent { - eventId: string; - ts: string; - sid: string; - kind: string; - writerPid: number; - vendor?: string; - vendorSid?: string; - parentEventId?: string; - causalityKey?: string; - payload?: Record; -} - -export interface SessionMeta { - sid: string; - schemaVersion: 1; - workflow?: string; - category: string; - status: "active" | "completed" | "failed"; - createdAt?: string; - currentPhase?: string; - gatesPassedBy: Array>; - pendingPeerReviews: Array>; -} - -export function createEventId(now = Date.now()): string { - const time = now.toString(36).padStart(10, "0"); - const random = Math.random().toString(36).slice(2, 10).padEnd(8, "0"); - return `${time}${random}`; -} - -export function eventsPath(projectDir: string, sid: string): string { - return join(sessionDir(projectDir, sid), "events.jsonl"); -} - -export function metaPath(projectDir: string, sid: string): string { - return join(sessionDir(projectDir, sid), "meta.json"); -} +import { + emitEvent as appendEvent, + ensureParent, + type OmaEvent, + retryObservePath, + SEMANTIC_EVENT_KINDS, +} from "./state-core.ts"; + +export { + createEventId, + createSessionId, + deriveMeta, + eventsPath, + metaPath, + type OmaEvent, + readEvents, + refreshMeta, + type SessionMeta, + sortEvents, + vendorHomePayload, +} from "./state-core.ts"; export async function emitEvent( projectDir: string, sid: string, event: Omit, "sid"> & { kind: string }, ): Promise { - const enriched: OmaEvent = { - eventId: event.eventId ?? createEventId(), - ts: event.ts ?? new Date().toISOString(), - sid, - kind: event.kind, - writerPid: event.writerPid ?? process.pid, - vendor: event.vendor, - vendorSid: event.vendorSid, - parentEventId: event.parentEventId, - causalityKey: event.causalityKey, - payload: event.payload, - }; - const path = eventsPath(projectDir, sid); - try { - mkdirSync(dirname(path), { recursive: true }); - appendFileSync(path, `${JSON.stringify(enriched)}\n`, "utf-8"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`[oma] L1 events.jsonl write failed: ${msg}\n`); - process.stderr.write(`[oma] path=${path}\n`); - process.stderr.write( - "[oma] hint: run 'oma doctor' to diagnose disk/permission/corruption\n", - ); - throw e; - } - if ( - event.kind === "session.created" || - event.kind === "workflow.phase" || - event.kind === "session.ended" - ) { - refreshMeta(projectDir, sid); - } - if (SEMANTIC_KINDS.has(enriched.kind)) { + const enriched = appendEvent(projectDir, sid, event); + if (SEMANTIC_EVENT_KINDS.has(enriched.kind)) { const observed = await observeWithTimeout({ sessionId: sid, content: `${JSON.stringify(enriched)}\n`, source: "oma-workflow", projectDir, }); - if (!observed) enqueueRetry(projectDir, enriched); - } - return enriched; -} - -function enqueueRetry(projectDir: string, event: OmaEvent): void { - const path = join(projectDir, RETRY_FILE); - mkdirSync(dirname(path), { recursive: true }); - appendFileSync(path, `${JSON.stringify(event)}\n`, "utf-8"); -} - -export function sortEvents(events: OmaEvent[]): OmaEvent[] { - return [...events].sort((a, b) => { - const ts = a.ts.localeCompare(b.ts); - if (ts !== 0) return ts; - return a.eventId.localeCompare(b.eventId); - }); -} - -export function readEvents(projectDir: string, sid: string): OmaEvent[] { - const path = eventsPath(projectDir, sid); - if (!existsSync(path)) return []; - const events: OmaEvent[] = []; - for (const line of readFileSync(path, "utf-8").split("\n")) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as OmaEvent; - if (event.sid && event.kind && event.eventId && event.ts) { - events.push(event); + if (!observed) { + const path = retryObservePath(projectDir); + try { + ensureParent(path); + const fd = openSync(path, "a", 0o600); + try { + // Separate a prior interrupted row without replacing the append-only + // log or invalidating an already-open writer. Legacy rows retry observe. + writeFileSync(fd, `\n${JSON.stringify(enriched)}\n`, "utf-8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } + } catch (error) { + // L1 is already authoritative; an optional retry-log failure must not + // make callers repeat the event or restore completed workflow state. + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[oma] observation retry write failed: ${message}\n`, + ); + process.stderr.write(`[oma] path=${path}\n`); } - } catch { - // Bad lines are ignored here; doctor/state repair can quarantine. } } - return sortEvents(events); -} - -export function deriveMeta(sid: string, events: OmaEvent[]): SessionMeta { - const meta: SessionMeta = { - sid, - schemaVersion: 1, - category: "main", - status: "active", - gatesPassedBy: [], - pendingPeerReviews: [], - }; - for (const event of sortEvents(events)) { - if (event.kind === "session.created") { - meta.createdAt = meta.createdAt ?? event.ts; - meta.workflow = String(event.payload?.workflow ?? meta.workflow ?? ""); - meta.category = String(event.payload?.category ?? meta.category); - } else if (event.kind === "workflow.phase") { - meta.currentPhase = String(event.payload?.phase ?? ""); - } else if (event.kind === "gate.passed") { - meta.gatesPassedBy.push({ ts: event.ts, ...(event.payload ?? {}) }); - } else if (event.kind === "session.ended") { - meta.status = event.payload?.status === "failed" ? "failed" : "completed"; - } - } - return meta; -} - -export function refreshMeta(projectDir: string, sid: string): SessionMeta { - const meta = deriveMeta(sid, readEvents(projectDir, sid)); - atomicWriteJson(metaPath(projectDir, sid), meta); - return meta; + return enriched; } diff --git a/.agents/hooks/core/state-index-lock.ts b/.agents/hooks/core/state-index-lock.ts new file mode 100644 index 0000000..ce479f9 --- /dev/null +++ b/.agents/hooks/core/state-index-lock.ts @@ -0,0 +1,139 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { projectStateDir } from "./session-storage.ts"; + +const sleeper = new Int32Array(new SharedArrayBuffer(4)); + +function removeOwner(directory: string, owner: string): void { + try { + // Never recursively delete a published lock: another process may already + // have acquired it. Its unique owner file keeps rmdir from removing it. + unlinkSync(join(directory, owner)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + try { + rmdirSync(directory); + } catch (error) { + if ( + !["ENOENT", "ENOTEMPTY", "EEXIST"].includes( + (error as NodeJS.ErrnoException).code ?? "", + ) + ) + throw error; + } +} + +function recoverDeadOwner(directory: string): void { + try { + const owners = readdirSync(directory); + if (owners.length === 0) { + // Windows does not replace even an empty destination directory. + // rmdir succeeds only while empty, never after a new owner publishes. + rmdirSync(directory); + return; + } + for (const owner of owners) { + if (!owner.startsWith("owner-")) continue; + const meta = JSON.parse(readFileSync(join(directory, owner), "utf-8")); + if ( + meta.hostname !== hostname() || + !Number.isSafeInteger(meta.pid) || + meta.pid <= 0 + ) + continue; + try { + process.kill(meta.pid, 0); + } catch (error) { + // EPERM is a live process we cannot signal, not a stale lock. + if ((error as NodeJS.ErrnoException).code === "ESRCH") + removeOwner(directory, owner); + } + } + } catch { + // A racing release/recovery or unreadable owner never grants ownership. + // Only publishing our nonempty directory below can acquire the lock. + } +} + +/** Shared by the standalone Bun hooks and the CLI; no installed dependencies. */ +export function withStateIndexLock( + projectDir: string, + action: () => T, + timeoutMs = 2000, +): T { + return withLock(projectDir, "session-index", action, timeoutMs); +} + +/** Serializes session writes with the final migration copy and path switch. */ +export function withSessionWriteLock( + projectDir: string, + sid: string, + action: () => T, +): T { + // Hashing also keeps legacy session IDs from becoming lock path components. + return withLock( + projectDir, + `session-${createHash("sha256").update(sid).digest("hex")}`, + action, + 2000, + ); +} + +function withLock( + projectDir: string, + name: string, + action: () => T, + timeoutMs: number, +): T { + const root = join(projectStateDir(projectDir), "locks"); + mkdirSync(root, { recursive: true, mode: 0o700 }); + const lock = join(root, name); + const owner = `owner-${process.pid}-${randomUUID()}`; + const candidate = join(root, owner); + mkdirSync(candidate); + let acquired = false; + try { + writeFileSync( + join(candidate, owner), + JSON.stringify({ pid: process.pid, hostname: hostname() }), + ); + const deadline = performance.now() + timeoutMs; + while (!acquired) { + try { + // A populated directory cannot replace another populated directory. + // Publishing the owner and lock together avoids an empty-lock crash + // window. Empty directories left by interrupted release are reusable. + renameSync(candidate, lock); + acquired = true; + } catch (error) { + if ( + !["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes( + (error as NodeJS.ErrnoException).code ?? "", + ) + ) + throw error; + recoverDeadOwner(lock); + if (performance.now() >= deadline) { + throw new Error( + `Timed out waiting for state index lock: ${lock}. Another process may still be updating the session index.`, + ); + } + Atomics.wait(sleeper, 0, 0, 10); + } + } + return action(); + } finally { + removeOwner(acquired ? lock : candidate, owner); + } +} diff --git a/.agents/hooks/core/state-marker.ts b/.agents/hooks/core/state-marker.ts index 97ef1b2..14cace9 100644 --- a/.agents/hooks/core/state-marker.ts +++ b/.agents/hooks/core/state-marker.ts @@ -1,146 +1,16 @@ -#!/usr/bin/env bun -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - renameSync, - statSync, - writeFileSync, -} from "node:fs"; -import { dirname, join } from "node:path"; - -export interface LastSessionMarker { - vendor: string; - vendorSid: string; - ts: string; -} - -export interface StateIndex { - schemaVersion: 1; - lastSession?: LastSessionMarker; - active: Record; -} - -// Mirror cli/constants/paths.ts → AGENTS_STATE_SESSIONS_DIR (hooks cannot import cli/). -export const STATE_ROOT = join(".agents", "state", "sessions"); - -export function sessionsDir(projectDir: string): string { - return join(projectDir, STATE_ROOT); -} - -export function indexPath(projectDir: string): string { - return join(sessionsDir(projectDir), "_index.json"); -} - -export function sessionDir(projectDir: string, sid: string): string { - return join(sessionsDir(projectDir), sid); -} - -export function defaultIndex(): StateIndex { - return { schemaVersion: 1, active: {} }; -} - -function ensureParent(path: string): void { - mkdirSync(dirname(path), { recursive: true }); -} - -function fsyncParent(path: string): void { - try { - const fd = openSync(dirname(path), "r"); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } - } catch { - // Directory fsync is not universally supported. - } -} - -export function atomicWriteJson(path: string, value: unknown): void { - ensureParent(path); - const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); - const fd = openSync(tmp, "r"); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } - renameSync(tmp, path); - fsyncParent(path); -} - -export function readIndex(projectDir: string): StateIndex { - const path = indexPath(projectDir); - if (!existsSync(path)) return defaultIndex(); - try { - const parsed = JSON.parse( - readFileSync(path, "utf-8"), - ) as Partial; - return { - schemaVersion: 1, - active: parsed.active ?? {}, - lastSession: parsed.lastSession, - }; - } catch { - return defaultIndex(); - } -} - -export function updateIndex( - projectDir: string, - mutate: (index: StateIndex) => void, - maxRetries = 3, -): StateIndex { - const path = indexPath(projectDir); - for (let attempt = 0; attempt < maxRetries; attempt++) { - const before = existsSync(path) ? statSync(path).mtimeMs : null; - const next = readIndex(projectDir); - mutate(next); - const after = existsSync(path) ? statSync(path).mtimeMs : null; - if (before !== after) continue; - atomicWriteJson(path, next); - return next; - } - // D69: CAS retries exhausted. Do NOT throw or corrupt _index.json — leave it - // stale and emit a diagnostic. The next hook fire or `oma state repair` - // re-derives active pointers from events.jsonl. - process.stderr.write( - `[oma] _index.json CAS retries exhausted (${maxRetries}); leaving it stale.\n`, - ); - process.stderr.write( - "[oma] hint: run 'oma state repair' to re-derive active pointers\n", - ); - return readIndex(projectDir); -} - -export function getActiveSid( - index: StateIndex, - category = "main", -): string | null { - return index.active[category] ?? index.active.main ?? null; -} - -export function setActiveSession( - projectDir: string, - category: string, - sid: string, -): StateIndex { - return updateIndex(projectDir, (index) => { - index.active[category] = sid; - }); -} - -export function setLastSession( - projectDir: string, - vendor: string, - vendorSid: string, -): StateIndex { - return updateIndex(projectDir, (index) => { - index.lastSession = { vendor, vendorSid, ts: new Date().toISOString() }; - }); -} +// Compatibility surface for installed hook consumers. +export { + atomicWriteJson, + defaultIndex, + getActiveSid, + indexPath, + type LastSessionMarker, + readIndex, + STATE_ROOT, + type StateIndex, + sessionDir, + sessionsDir, + setActiveSession, + setLastSession, + updateIndex, +} from "./state-core.ts"; diff --git a/.agents/hooks/core/test-filter.ts b/.agents/hooks/core/test-filter.ts index 25ba1cd..c5ea37b 100644 --- a/.agents/hooks/core/test-filter.ts +++ b/.agents/hooks/core/test-filter.ts @@ -1,110 +1,15 @@ // 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"; - -// --- Vendor detection (same logic as keyword-detector.ts) --- - -function detectVendor(input: Record): Vendor { - const event = input.hook_event_name as string | undefined; - const _hookEventName = input.hookEventName as string | undefined; - - // pi spawns this script from `.pi/extensions/oma/`; trust the script path. - if (import.meta.filename.includes(`${join(".pi", "extensions")}`)) - return "pi"; - - 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"; - if (event === "PreToolUse") { - if ("session_id" in input && !("sessionId" in input)) return "codex"; - } - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - 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"); - } -} +import type { HandlerCtx, HandlerResult, HookInput } from "./types.ts"; +import { + detectVendorFromInput, + getHookDir, + getProjectDir, +} from "./vendor-detect.ts"; // --- Test runner patterns --- @@ -168,7 +73,7 @@ interface PreToolUseInput { * Returns a `mutate` HandlerResult when a test command should be piped through * the failure-filter script, or `null` when the input is not a test command / * the filter script is not installed. - * `ctx.cwd` must be the resolved git-root project directory. + * `ctx.cwd` must be the resolved project root (see fs-utils resolveProjectRoot). */ export async function run( input: HookInput, @@ -176,29 +81,59 @@ export async function run( ): Promise { if (input.kind !== "pre_tool") return null; - const { toolName, toolInput, cwd: projectDir } = input; + const { toolName, toolInput } = input; const { vendor } = ctx; - - // Gemini uses run_shell_command; Claude-family uses Bash. - if (toolName !== "Bash" && toolName !== "run_shell_command") return null; + // Filter scripts live under the project root. ctx.cwd is the resolved root + // even after the session cd's into a subdirectory; input.cwd is not. + const projectDir = ctx.cwd || input.cwd; + + // 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; + // The rewrite below is Bash-only (`set -o pipefail`, subshell, pipe to + // bash). On Windows the host shell is PowerShell/cmd, which fails to parse + // it before the test runner even starts (#618). Losing the failure filter + // is acceptable; breaking `npm test` is not. + if (process.platform === "win32") return null; + + // Hook re-entry guard: a command already piping through the filter script + // must pass through unchanged, not get wrapped a second time (#618). + if (command.includes("filter-test-output.sh")) return null; + const isTestCommand = TEST_PATTERNS.some((p) => p.test(command)); if (!isTestCommand) return null; 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; - - const filteredCmd = `set -o pipefail; (${command}) 2>&1 | bash "${filterScript}"`; + 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; + + // The original command sits on its own lines inside the subshell: a + // trailing `# comment` would otherwise swallow the closing paren, and a + // heredoc's terminator must stay alone on its line (`EOF)` never matches). + const filteredCmd = `set -o pipefail; (\n${command}\n) 2>&1 | bash "${filterScript}"`; const updatedInput: Record = { ...toolInput, command: filteredCmd, @@ -223,7 +158,7 @@ function main() { const parsed: PreToolUseInput = JSON.parse(raw); - const vendor = detectVendor(parsed); + const vendor = detectVendorFromInput(parsed, "tool", import.meta.filename); const projectDir = getProjectDir(vendor, parsed); // Build canonical HookInput and delegate to run() — single logic source. diff --git a/.agents/hooks/core/triggers.json b/.agents/hooks/core/triggers.json index 29747e0..cc49fbb 100644 --- a/.agents/hooks/core/triggers.json +++ b/.agents/hooks/core/triggers.json @@ -2,28 +2,39 @@ "workflows": { "orchestrate": { "persistent": true, + "explicit": [ + "orchestrate", + "オーケストレート", + "orquestar", + "orchestrer", + "orchestrieren", + "orquestrar", + "оркестровать", + "orkestreren", + "orkiestrować" + ], "keywords": { "*": ["orchestrate"], "en": [ - "parallel", "do everything", "run everything", "do it all", "run all tasks", - "automate", "execute everything", - "handle everything" + "handle everything", + "everything in parallel", + "automate everything", + "automate the whole", + "automate it all" ], "ko": [ - "자동 실행", - "자동으로 실행", "자동으로 해", - "병렬 실행", "전부 실행", "전부 해", "전부 돌려", "자동으로 해줘", - "병렬로 해줘", + "전부 병렬로", + "병렬로 전부", "전부 해줘", "전부 돌려줘", "한번에 다 해줘", @@ -34,21 +45,18 @@ ], "ja": [ "オーケストレート", - "並列実行", - "自動実行", "全部実行", "全部やって", "自動でやって", - "並列でやって", + "全部並列で", "全部お願い", "まとめてやって" ], "zh": [ "编排", - "并行执行", - "自动执行", "全部执行", "全部做", + "全部并行", "自动处理", "一起做", "全做了", @@ -56,80 +64,74 @@ ], "es": [ "orquestar", - "paralelo", "ejecutar todo", "hazlo todo", "ejecuta todo", - "automatiza", + "todo en paralelo", "haz todo" ], "fr": [ "orchestrer", - "parallèle", "tout exécuter", "fais tout", "exécute tout", - "automatise", + "tout en parallèle", "gère tout" ], "de": [ "orchestrieren", - "parallel", "alles ausführen", "mach alles", "alles erledigen", - "automatisieren", + "alles parallel", "alles auf einmal" ], "pt": [ "orquestrar", - "paralelo", "executar tudo", "faça tudo", "execute tudo", - "automatize", + "tudo em paralelo", "resolva tudo" ], "ru": [ "оркестровать", - "параллельно", "выполнить всё", "сделай всё", "запусти всё", - "автоматизируй", + "всё параллельно", "всё сразу" ], "nl": [ "orkestreren", - "parallel", "alles uitvoeren", "doe alles", "voer alles uit", - "automatiseer", + "alles parallel", "alles tegelijk" ], "pl": [ "orkiestrować", - "równolegle", "wykonaj wszystko", "zrób wszystko", "uruchom wszystko", - "zautomatyzuj", + "wszystko równolegle", "wszystko naraz" ] }, "patterns": { "*": [ - "\\b(build|create|make|develop|implement|scaffold)\\s+(?:me\\s+)?(?:an?|the)\\s+(?:[\\w-]+\\s+){0,3}(app|api|service|server|cli|tool|website|dashboard|system|feature|backend|frontend|prototype|mvp|bot)\\b", - "\\bi\\s+want\\s+(?:a|an)\\s+(?:[\\w-]+\\s+){0,3}(app|api|service|server|cli|tool|website|dashboard|system|feature|backend|frontend|prototype|mvp|bot)\\b" + "\\b(build|create|make|develop|implement|scaffold)\\s+(?:me\\s+)?(?:an?)\\s+(?:[\\w-]+\\s+){0,3}(app|api|service|server|cli|tool|website|dashboard|system|backend|frontend|prototype|mvp|bot)\\b", + "\\bi\\s+want\\s+(?:a|an)\\s+(?:[\\w-]+\\s+){0,3}(app|api|service|server|cli|tool|website|dashboard|system|backend|frontend|prototype|mvp|bot)\\b" ], "ko": [ - "(앱|API|서비스|서버|CLI|도구|웹사이트|대시보드|시스템|기능|백엔드|프론트엔드|프로토타입|MVP|봇)\\s*(?:을|를|이|가)?\\s*(?:만들어\\s*(?:주세요|줘|줄래)?|구현해\\s*(?:주세요|줘|줄래)?|개발해\\s*(?:주세요|줘|줄래)?|만들자|구현하자|개발하자)" + "(앱|API|서비스|서버|CLI|도구|웹사이트|대시보드|시스템|백엔드|프론트엔드|프로토타입|MVP|봇)\\s*(?:을|를|이|가)?\\s*(?:만들어\\s*(?:주세요|줘|줄래)?|구현해\\s*(?:주세요|줘|줄래)?|개발해\\s*(?:주세요|줘|줄래)?|만들자|구현하자|개발하자)" ] } }, "ultrawork": { "persistent": true, + "explicit": ["ultrawork", "ulw"], "keywords": { "*": ["ultrawork", "ulw"] } @@ -828,15 +830,10 @@ }, "work": { "persistent": true, + "explicit": ["work mode", "work workflow"], "keywords": { - "*": ["work", "step by step"], - "en": [ - "one by one", - "guide me", - "walk me through", - "manual mode", - "one step at a time" - ], + "*": ["step by step", "work mode", "work workflow"], + "en": ["one by one", "one step at a time"], "ko": [ "단계별", "단계별로", @@ -845,83 +842,21 @@ "하나씩 해줘", "순서대로 해줘", "차근차근", - "차근차근 해줘", - "수동으로 해줘" - ], - "ja": [ - "ステップバイステップ", - "一歩ずつ", - "ガイドして", - "手動で", - "一つずつ", - "順番にやって", - "手順を教えて" - ], - "zh": [ - "逐步", - "一步一步", - "指导我", - "手动", - "一个一个", - "按顺序", - "带我做" - ], - "es": [ - "paso a paso", - "guíame", - "uno por uno", - "modo manual", - "de a uno", - "llévame paso a paso" - ], - "fr": [ - "étape par étape", - "guide-moi", - "un par un", - "mode manuel", - "accompagne-moi", - "montre-moi étape par étape" + "차근차근 해줘" ], + "ja": ["ステップバイステップ", "一歩ずつ", "一つずつ", "順番にやって"], + "zh": ["逐步", "一步一步", "一个一个", "按顺序"], + "es": ["paso a paso", "uno por uno", "de a uno"], + "fr": ["étape par étape", "un par un"], "de": [ "schritt für schritt", - "führe mich", "eins nach dem anderen", - "manueller modus", - "zeig mir wie", "der reihe nach" ], - "pt": [ - "passo a passo", - "me guie", - "um por um", - "modo manual", - "me acompanhe", - "me mostre passo a passo" - ], - "ru": [ - "шаг за шагом", - "направь меня", - "по одному", - "ручной режим", - "покажи по шагам", - "веди меня" - ], - "nl": [ - "stap voor stap", - "begeleid me", - "een voor een", - "handmatige modus", - "leid me erdoor", - "stap voor stap uitleg" - ], - "pl": [ - "krok po kroku", - "poprowadź mnie", - "jeden po drugim", - "tryb ręczny", - "przeprowadź mnie", - "pokaż krok po kroku" - ] + "pt": ["passo a passo", "um por um"], + "ru": ["шаг за шагом", "по одному"], + "nl": ["stap voor stap", "een voor een"], + "pl": ["krok po kroku", "jeden po drugim"] } }, "deepinit": { @@ -1255,6 +1190,7 @@ }, "ralph": { "persistent": true, + "explicit": ["ralph", "랄프"], "keywords": { "*": ["ralph"], "en": [ @@ -1262,7 +1198,7 @@ "do not stop", "must complete", "until done", - "keep going", + "keep going until", "finish everything", "complete everything", "run to completion", @@ -1270,7 +1206,6 @@ "see it through", "don't give up", "never stop", - "carry on", "push through", "finish it all", "complete all" @@ -1279,20 +1214,17 @@ "랄프", "멈추지마", "멈추지 말고", - "끝까지", "완료될때까지", "될때까지 해", "끝날때까지", "다 끝내", - "다 해", "전부 완료", "끝까지 해", "중단하지마", "끝장내", "끝까지 해줘", "다 끝내줘", - "계속해", - "계속 해줘", + "때까지 계속", "그만두지마", "포기하지마", "멈추지 말아줘" @@ -1306,7 +1238,6 @@ "完了するまで", "最後までやって", "諦めないで", - "続けて", "中断しないで", "全部やりきって" ], @@ -1319,7 +1250,6 @@ "做完为止", "一直做到完", "别放弃", - "继续做", "不要中断", "全部做完" ], @@ -1331,7 +1261,6 @@ "no te detengas", "completa todo", "no abandones", - "sigue adelante", "acaba todo" ], "fr": [ @@ -1342,7 +1271,7 @@ "ne t'arrête pas", "complète tout", "n'abandonne pas", - "continue", + "continue sans t'arrêter", "finis tout" ], "de": [ @@ -1353,7 +1282,6 @@ "nicht aufhören", "mach alles fertig", "gib nicht auf", - "mach weiter", "alles abschließen" ], "pt": [ @@ -1364,7 +1292,7 @@ "complete tudo", "não pare até terminar", "não desista", - "continue", + "continue sem parar", "finalize tudo" ], "ru": [ @@ -1375,7 +1303,6 @@ "не прекращай", "доделай всё", "не сдавайся", - "продолжай", "закончи всё" ], "nl": [ @@ -1386,7 +1313,6 @@ "niet stoppen", "alles afronden", "geef niet op", - "ga door", "maak alles klaar" ], "pl": [ @@ -1397,7 +1323,6 @@ "nie przerywaj", "skończ wszystko", "nie poddawaj się", - "kontynuuj", "zakończ wszystko" ] } @@ -2067,7 +1992,8 @@ "react", "tailwind", "tsx", - "frontend" + "frontend", + "angular" ], "en": [ "make a react component", @@ -2086,7 +2012,8 @@ "넥스트 페이지", "tailwind로 스타일", "shadcn 붙여줘", - "프론트 구현" + "프론트 구현", + "앵귤러" ], "ja": [ "reactコンポーネント", @@ -2452,7 +2379,7 @@ ] } }, - "oma-orchestrator": { + "oma-orchestration": { "keywords": { "*": ["MCP Memory"], "en": [ @@ -2599,14 +2526,32 @@ "rebase workflow", "git worktree" ], - "ko": ["머지 충돌 해결", "리베이스해줘", "워크트리 써줘"], + "ko": [ + "머지 충돌 해결", + "리베이스해줘", + "워크트리 써줘", + "브랜칭 전략", + "브랜치 전략 세워줘", + "릴리즈 태그 달아줘", + "체리픽해줘" + ], "ja": [ "マージ衝突解決", "リベースして", "リリースタグ", - "worktree使って" + "worktree使って", + "ブランチ戦略", + "チェリーピック", + "リリースタグ付けて" ], - "zh": ["解决合并冲突", "帮我 rebase", "打发布标签", "用 worktree"] + "zh": [ + "解决合并冲突", + "帮我 rebase", + "打发布标签", + "用 worktree", + "分支策略", + "帮我 cherry-pick" + ] } }, "oma-search": { @@ -2709,7 +2654,7 @@ ] } }, - "oma-translator": { + "oma-translation": { "keywords": { "*": ["arb", "i18n"], "en": [ @@ -3033,6 +2978,63 @@ "会议转录" ] } + }, + "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,18 +3068,26 @@ "should you", "could we", "would you", - "what if", "what about", "why build", "why create", "why make", - "artifact" + "artifact", + "is this a bug", + "is it a bug", + "is that a bug", + "is this an error", + "why did it", + "why does it" ], "ko": [ "뭐야", "뭐임", "무엇", - "어떻게", + "어떻게 동작", + "어떻게 작동", + "어떻게 되는지", + "어떻게 하는지", "설명해", "알려줘", "키워드", @@ -3093,20 +3103,27 @@ "왜 만들", "어떻게 만들", "어떨까", - "하면 좋을", + "하면 좋을지", "한다면", "할까요", "보강할", "에 대해", "에 대한", - "한번 봐", "깊게 봐", "코드를 한번", "그 워크플로우", "이 워크플로우", "워크플로우 자체", "고도화", - "아티팩트" + "아티팩트", + "버그임", + "버그인가", + "버그인가요", + "버그야", + "버그인지", + "문제인가", + "문제임", + "에러인가" ], "ja": [ "とは", @@ -3175,8 +3192,7 @@ "wykryto" ] }, - "excludedWorkflows": ["tools", "stack-set"], - "cjkScripts": ["ko", "ja", "zh"], + "excludedWorkflows": ["tools", "stack-set", "schedule", "convert"], "extensionRouting": { "frontend-engineer": [ "tsx", diff --git a/.agents/hooks/core/types.ts b/.agents/hooks/core/types.ts index 20ba28c..ec045b9 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. @@ -8,8 +8,8 @@ // HandlerResult, HandlerCtx, HookHandler). It is self-contained — no imports // from `cli/` — so that both `cli/` and the `.agents/hooks/core/` standalone // scripts can import from here without creating a circular dependency. -// `cli/commands/hook/types.ts` re-exports these symbols plus the transport -// envelope types (HookRequest, HookResponse, HookTransport). +// `cli/commands/hook/types.ts` re-exports these symbols plus the dispatch +// envelope types (HookRequest, HookResponse). import type { VENDORS } from "./constants.ts"; @@ -28,18 +28,52 @@ 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 stopReason?: string; } +/** + * Optional goal contract for a persistent workflow (design-prime-agent-adoption + * Track B). Written by `oma goal set`; read by the persistent-mode Stop hook. + */ +export interface ModeGoal { + /** Human description of the objective. Informational only. */ + description?: string; + budget?: { + /** + * Wall-clock budget in minutes, measured from `activatedAt`. When + * exceeded the Stop hook deactivates the workflow and allows an honest + * partial stop (machine verdict, not model discretion). + */ + wallClockMinutes?: number; + }; + completion?: { + /** + * Deterministic stop gate. MUST be an allowlist keyword ("typecheck" | + * "test" | "lint") that maps to an existing package.json script; the hook + * runs it as an argv array with no shell. Free-form strings are NEVER + * executed — this value lives in an agent-writable state file, so + * executing it verbatim would be an arbitrary-command-execution path + * that bypasses the PreToolUse permission layer. + */ + gate?: string; + }; +} + export interface ModeState { workflow: string; sessionId: string; activatedAt: string; reinforcementCount: number; + /** + * L1 session id (`oma-…`) recorded at activation so the Stop hook can emit + * gate.passed / gate.failed events onto the same events.jsonl trail. + */ + omaSid?: string; + goal?: ModeGoal; } // --------------------------------------------------------------------------- @@ -54,20 +88,39 @@ 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; toolInput: Record; cwd: string; } + | { + kind: "post_tool"; + toolName: string; + toolInput: Record; + /** Vendor tool result payload (claude: `tool_response`), when provided. */ + toolResponse?: Record; + cwd: string; + } | { kind: "stop"; cwd: string; /** * Assistant response / transcript text from the stop payload, if any. * Carries deactivation phrases ("workflow done") so persistent-mode can - * deactivate via the central `oma hook` path, matching the standalone path. + * deactivate via the central `oma hook run` path, matching the standalone path. */ responseText?: string; }; @@ -85,11 +138,25 @@ export type HandlerResult = | { type: "mutate"; updatedInput: Record } | { type: "block"; reason: string }; +/** + * Resolved project config (CUE + YAML + local overlay) as loaded by the CLI. + * Structural on purpose: this file stays free of `cli/` imports, and each + * handler validates only the keys it reads. + */ +export type HookConfig = Record; + /** Context passed to every handler alongside the normalized HookInput. */ export interface HandlerCtx { vendor: Vendor; + /** Resolved OMA project root (config and state live under its `.agents/`). */ cwd: string; sid?: string; + /** + * Project config loaded once by `oma hook run` from `/.agents/`. Absent + * in standalone runs, when the project has no config, or when loading + * failed; handlers then fall back to their own YAML readers. + */ + config?: HookConfig; } /** Interface every centralized handler must implement. */ diff --git a/.agents/hooks/core/vendor-detect.ts b/.agents/hooks/core/vendor-detect.ts new file mode 100644 index 0000000..ae36413 --- /dev/null +++ b/.agents/hooks/core/vendor-detect.ts @@ -0,0 +1,174 @@ +// Shared vendor-detection helpers for the core hook handlers. +// +// Previously each handler (keyword-detector, state-boundary, skill-injector, +// code-intelligence-primer, persistent-mode, test-filter) carried its own copy +// of these functions, which drifted (different vendor subsets). They live here +// once and every handler imports them. The event-name → vendor mapping is +// hook-kind-specific (prompt/tool/stop), so detectVendorFromInput keeps one +// precedence list per kind. + +import { join } from "node:path"; +import { agyProjectDir, isAgyInput } from "./agy-input.ts"; +import { resolveProjectRoot } 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; +} + +/** Which hook payload a standalone handler parses. */ +export type HookPayloadKind = "prompt" | "tool" | "stop"; + +/** + * Vendor of a raw hook payload, for standalone runs (`oma hook run` passes + * --vendor explicitly). Each kind keeps its own precedence because vendors + * name the same event differently (UserPromptSubmit vs userPromptSubmit vs + * PreInvocation, ...). `scriptPath` is the calling handler's + * `import.meta.filename`; prompt hooks trust their install dir first and tool + * hooks recognize the pi bridge dir. + */ +export function detectVendorFromInput( + input: Record, + kind: HookPayloadKind, + scriptPath: string, +): Vendor { + const event = input.hook_event_name as string | undefined; + const hookEventName = input.hookEventName as string | undefined; + const env = process.env; + // Codex sends snake_case session_id; Claude sends camelCase sessionId. + const codexSessionShape = "session_id" in input && !("sessionId" in input); + + if (kind === "prompt") { + const byScriptPath = inferVendorFromScriptPath(scriptPath); + if (byScriptPath) return byScriptPath; + // agy sends no hook_event_name; detect it by its stdin shape. + if (isAgyInput(input)) return "antigravity"; + if (env.GROK_WORKSPACE_ROOT) return "grok"; + if ( + env.KIRO_PROJECT_DIR || + event === "userPromptSubmit" || + hookEventName === "userPromptSubmit" + ) { + return "kiro"; + } + if (event === "PreInvocation") return "antigravity"; + if (event === "beforeSubmitPrompt") return "cursor"; + if (event === "UserPromptSubmit" && codexSessionShape) return "codex"; + } else if (kind === "stop") { + if (env.GROK_WORKSPACE_ROOT) return "grok"; + if (env.KIRO_PROJECT_DIR || event === "stop" || hookEventName === "stop") { + return "kiro"; + } + if (isAgyInput(input)) return "antigravity"; + if (event === "Stop" && env.ANTIGRAVITY_PROJECT_DIR) return "antigravity"; + if (event === "Stop" && codexSessionShape) return "codex"; + } else { + // pi spawns tool hooks from `.pi/extensions/oma/`; trust that path. + if (scriptPath.includes(join(".pi", "extensions"))) return "pi"; + if (env.GROK_WORKSPACE_ROOT) return "grok"; + if (env.KIRO_PROJECT_DIR) return "kiro"; + if (event === "preToolUse" || hookEventName === "preToolUse") return "kiro"; + if (event === "PreToolUse" && env.ANTIGRAVITY_PROJECT_DIR) { + return "antigravity"; + } + if (event === "PreToolUse" && codexSessionShape) return "codex"; + } + // Qwen Code sets QWEN_PROJECT_DIR; Claude sets CLAUDE_PROJECT_DIR. + if (env.QWEN_PROJECT_DIR) return "qwen"; + return "claude"; +} + +/** Resolve the OMA project root 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 resolveProjectRoot(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..d1471d1 100644 --- a/.agents/hooks/core/vendor-renderer.ts +++ b/.agents/hooks/core/vendor-renderer.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { stripVTControlCharacters } from "node:util"; import type { OmaEvent } from "./state-emit.ts"; import type { Vendor } from "./types.ts"; @@ -14,32 +15,132 @@ export interface StateSnapshotRenderInput { reason: string; recentEvents: OmaEvent[]; facts?: MemoryFact[]; + /** Harness changes promoted since the last session that showed them. */ + evolution?: string[]; +} + +const MAX_SNAPSHOT_CHARS = 12_000; +const MAX_RECENT_EVENTS = 12; +const EVENT_FIELDS: Record = { + "decision.made": ["subject", "decision", "rationale", "instanceId"], + "decision.missing": [ + "workflow", + "checkpoint", + "missing", + "remediation", + "instanceId", + ], + "workflow.phase": ["phase", "workflow", "summary"], + "gate.passed": ["gate", "by", "workflow", "reviewer", "summary", "reason"], + "gate.failed": ["gate", "workflow", "summary", "reason", "timedOut"], + "blocker.raised": [ + "summary", + "code", + "severity", + "remediation", + "reason", + "phase", + "instanceId", + ], + "session.ended": ["status", "reason", "summary"], +}; + +export function snapshotText(value: unknown, limit = 160): string { + if ( + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) + return String(value); + if (typeof value !== "string") return ""; + return stripVTControlCharacters(value.slice(0, 4096)) + .replace(/[\p{Cc}\p{Cf}]/gu, " ") + .replace(/\s+/gu, " ") + .trim() + .slice(0, limit); +} + +export function renderEventPayload(event: OmaEvent): string { + return (EVENT_FIELDS[event.kind] ?? ["summary"]) + .map((field) => { + const value = + field === "missing" + ? renderMissingSubjects(event.payload?.[field]) + : snapshotText( + event.payload?.[field], + field === "code" ? 64 : field === "severity" ? 32 : 160, + ); + return value ? `${field}: ${JSON.stringify(value)}` : ""; + }) + .filter(Boolean) + .join(" | ") + .slice(0, 520); +} + +function renderMissingSubjects(value: unknown): string { + if (!Array.isArray(value)) return ""; + const subjects: string[] = []; + for (const record of value.slice(0, 20)) { + if (!record || typeof record !== "object" || Array.isArray(record)) + continue; + const raw = (record as Record).subject; + if (typeof raw !== "string") continue; + const subject = snapshotText(raw, 64); + if (!subject || subjects.includes(subject)) continue; + subjects.push(subject); + if (subjects.length === 3) break; + } + return subjects.join(", "); } function renderRecentEvents(events: OmaEvent[]): string[] { - if (events.length === 0) return ["- none"]; - return events.map((event) => `- ${event.ts} ${event.kind}`); + const seen = new Set(); + return events + .filter((event) => { + if (event.kind !== "boundary") return true; + if (seen.has(event.kind)) return false; + seen.add(event.kind); + return true; + }) + .slice(-MAX_RECENT_EVENTS) + .map((event) => { + if (event.kind === "boundary") return "- boundary"; + const details = renderEventPayload(event); + return [ + `- ${snapshotText(event.ts, 32)} ${snapshotText(event.kind, 72)}`, + ...(details ? [details] : []), + ] + .join(" | ") + .slice(0, 640); + }); } function renderMemoryFacts(facts: MemoryFact[]): string[] { if (facts.length === 0) return ["- none"]; - return facts.map((fact) => { - const source = fact.source ? ` (${fact.source})` : ""; - return `- ${fact.text}${source}`; + return facts.slice(0, 6).map((fact) => { + const sourceText = snapshotText(fact.source, 80); + const source = sourceText ? ` (${sourceText})` : ""; + return `- ${snapshotText(fact.text, 240)}${source}`; }); } function renderClaudeSnapshot(input: StateSnapshotRenderInput): string { const facts = input.facts ?? []; + const events = renderRecentEvents(input.recentEvents); return [ "[OMA STATE SNAPSHOT]", - `sid: ${input.sid}`, - `reason: ${input.reason}`, - "recent events:", - ...renderRecentEvents(input.recentEvents), - "memory facts:", - ...renderMemoryFacts(facts), - ].join("\n"); + `sid: ${snapshotText(input.sid, 160)}`, + `reason: ${snapshotText(input.reason, 240)}`, + ...(events.length ? ["recent events:", ...events] : []), + ...(facts.length ? ["memory facts:", ...renderMemoryFacts(facts)] : []), + ...(input.evolution?.length + ? [ + "harness evolved since your last session (oma skill promotions --all):", + ...input.evolution.slice(0, 6).map((line) => snapshotText(line, 200)), + ] + : []), + ] + .join("\n") + .slice(0, MAX_SNAPSHOT_CHARS); } export function renderStateSnapshot(input: StateSnapshotRenderInput): string { @@ -50,8 +151,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..6560d28 100644 --- a/.agents/hooks/variants/antigravity.json +++ b/.agents/hooks/variants/antigravity.json @@ -21,19 +21,27 @@ "timeout": 3 }, { - "hook": "serena-primer.ts", + "hook": "code-intelligence-primer.ts", "timeout": 3 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "run_command", - "timeout": 5 - }, - "Stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "write_to_file|replace_file_content|notebook_edit", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] }, "statusLine": { "hook": "hud.ts" diff --git a/.agents/hooks/variants/claude.json b/.agents/hooks/variants/claude.json index 901d5a9..2b1f2d4 100644 --- a/.agents/hooks/variants/claude.json +++ b/.agents/hooks/variants/claude.json @@ -6,33 +6,68 @@ "projectDirEnv": "CLAUDE_PROJECT_DIR", "runtime": "bun", "events": { + "SessionStart": [ + { + "hook": "code-intelligence-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", + "hook": "code-intelligence-primer.ts", + "timeout": 2 + } + ], + "PreToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "Bash", "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "Grep|Glob", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "Bash", + "timeout": 5 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "Bash", - "timeout": 5 - }, - "Stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "Edit|Write|NotebookEdit", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] }, "statusLine": { "hook": "hud.ts" @@ -43,6 +78,8 @@ "Bash(bun run:*)", "Bash(bunx tsx:*)", "Bash(git add:*)", + "Bash(ls *)", + "Bash(oma *)", "mcp__serena__*" ] } diff --git a/.agents/hooks/variants/codex.json b/.agents/hooks/variants/codex.json index 0609cab..58ba288 100644 --- a/.agents/hooks/variants/codex.json +++ b/.agents/hooks/variants/codex.json @@ -20,25 +20,43 @@ "timeout": 3 }, { - "hook": "serena-primer.ts", + "hook": "code-intelligence-primer.ts", "timeout": 3 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "Bash", - "timeout": 5 - }, - "Stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } - }, - "featureFlags": { - "file": ".codex/config.toml", - "section": "features", - "flags": { - "hooks": true - } + "PreToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "Bash", + "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "Bash", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "Bash", + "timeout": 5 + } + ], + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "apply_patch|Edit|Write", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] } } diff --git a/.agents/hooks/variants/commandcode.json b/.agents/hooks/variants/commandcode.json index 150f1ef..fea5bfe 100644 --- a/.agents/hooks/variants/commandcode.json +++ b/.agents/hooks/variants/commandcode.json @@ -6,9 +6,32 @@ "projectDirEnv": null, "runtime": "bun", "events": { - "Stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } + "SessionStart": [ + { + "hook": "code-intelligence-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } + ], + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "write|edit", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] } } diff --git a/.agents/hooks/variants/cursor.json b/.agents/hooks/variants/cursor.json index 8feb024..6fee10a 100644 --- a/.agents/hooks/variants/cursor.json +++ b/.agents/hooks/variants/cursor.json @@ -21,9 +21,53 @@ "timeout": 3 }, { - "hook": "serena-primer.ts", + "hook": "code-intelligence-primer.ts", "timeout": 3 } + ], + "preToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "Shell", + "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "Shell", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "Shell", + "timeout": 5 + } + ], + "afterFileEdit": [ + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ], + "stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30, + "loopLimit": null + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ], + "sessionStart": [ + { + "hook": "code-intelligence-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..2684be0 100644 --- a/.agents/hooks/variants/grok.json +++ b/.agents/hooks/variants/grok.json @@ -20,18 +20,43 @@ "timeout": 3 }, { - "hook": "serena-primer.ts", + "hook": "code-intelligence-primer.ts", "timeout": 3 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "run_terminal_cmd", - "timeout": 5 - }, - "Stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } + "PreToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "Bash", + "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "Bash", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "Bash", + "timeout": 5 + } + ], + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "hashline_edit|apply_patch|create_file|write_file|edit_file|str_replace", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] } } 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..42046b5 --- /dev/null +++ b/.agents/hooks/variants/kimi.json @@ -0,0 +1,63 @@ +{ + "$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": "code-intelligence-primer.ts", + "timeout": 3 + } + ], + "PreToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "Bash", + "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "Bash", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "Bash", + "timeout": 5 + } + ], + "PostToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "WriteFile|StrReplaceFile", + "timeout": 5 + } + ], + "Stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] + } +} diff --git a/.agents/hooks/variants/kiro.json b/.agents/hooks/variants/kiro.json index 8a01e24..024f24b 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": [ { @@ -20,18 +21,43 @@ "timeout": 3 }, { - "hook": "serena-primer.ts", + "hook": "code-intelligence-primer.ts", "timeout": 3 } ], - "preToolUse": { - "hook": "test-filter.ts", - "matcher": "shell", - "timeout": 5 - }, - "stop": { - "hook": "persistent-mode.ts", - "timeout": 5 - } + "preToolUse": [ + { + "hook": "scm-guard.ts", + "matcher": "shell", + "timeout": 3 + }, + { + "hook": "code-intelligence-guard.ts", + "matcher": "shell", + "timeout": 2 + }, + { + "hook": "test-filter.ts", + "matcher": "shell", + "timeout": 5 + } + ], + "postToolUse": [ + { + "hook": "refactor-guard.ts", + "matcher": "fs_write", + "timeout": 5 + } + ], + "stop": [ + { + "hook": "persistent-mode.ts", + "timeout": 30 + }, + { + "hook": "refactor-guard.ts", + "timeout": 5 + } + ] } } diff --git a/.agents/hooks/variants/opencode/oma.ts b/.agents/hooks/variants/opencode/oma.ts new file mode 100644 index 0000000..c21e80d --- /dev/null +++ b/.agents/hooks/variants/opencode/oma.ts @@ -0,0 +1,468 @@ +/** + * 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