From 2d7d467f7136d25a8e437eaf054d5b6e22999d99 Mon Sep 17 00:00:00 2001 From: Eunkwang Shin Date: Sun, 4 Oct 2026 22:35:13 +1100 Subject: [PATCH 1/2] fix(web): reduce next build memory usage Limit build workers to three and disable the Turbopack build filesystem cache. Run compile and generate in separate processes for local and Docker builds. Co-authored-by: First Fluke --- apps/web/Dockerfile | 2 ++ apps/web/next.config.ts | 7 +++++-- apps/web/package.json | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 63a4c4b7..76ded354 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -19,6 +19,8 @@ ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL WORKDIR /app/apps/web +# Compile and generate in separate processes so compiler memory is released +# before collecting page data and prerendering. Stop if either phase fails. RUN bun run build FROM base AS runner diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8692bc10..375adbbe 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,8 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { NextConfig } from "next"; - import { withSerwist } from "@serwist/turbopack"; +import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; import { env } from "./src/config/env"; @@ -61,6 +60,10 @@ const nextConfig: NextConfig = { }, experimental: { typedEnv: true, + // Limit build memory while upstream worker controls are pending: + // https://github.com/vercel/next.js/issues/95745 + cpus: 3, + turbopackFileSystemCacheForBuild: false, }, async headers() { return [ diff --git a/apps/web/package.json b/apps/web/package.json index 1f570238..3c999524 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,9 @@ "packageManager": "bun@1.4.2", "scripts": { "dev": "next dev --turbopack", - "build": "next build", + "build": "bun run build:compile && bun run build:generate", + "build:compile": "next build --experimental-build-mode compile", + "build:generate": "next build --experimental-build-mode generate", "start": "next start", "lint": "biome check --write --unsafe --error-on-warnings src", "format": "biome check --write --unsafe src", From 11d9aa7eaa864dcf6f31f86972e2f8c1d91b8404 Mon Sep 17 00:00:00 2001 From: Eunkwang Shin Date: Sun, 4 Oct 2026 22:36:03 +1100 Subject: [PATCH 2/2] chore: update oh-my-agent to 15.0.17 Refresh managed skills, workflows, hooks, and vendor adapters with oma update. Include the existing Serena Dart language server settings and YAML formatting changes. Co-authored-by: First Fluke --- .agents/agents/refactor-engineer.md | 2 +- .agents/agents/variants/claude.json | 32 +- .agents/agents/variants/commandcode.json | 32 +- .agents/agents/variants/grok.json | 35 +- .agents/agents/variants/kiro.json | 32 +- .agents/hooks/core/agentmemory-client.ts | 3 + .agents/hooks/core/code-intelligence-guard.ts | 42 +- .../hooks/core/code-intelligence-primer.ts | 100 ++-- .agents/hooks/core/event-contract.ts | 61 +++ .agents/hooks/core/fs-utils.ts | 63 ++- .agents/hooks/core/hud.ts | 63 ++- .agents/hooks/core/keyword-detector.ts | 313 +++++++---- .agents/hooks/core/persistent-mode.ts | 507 +++++++++++++++--- .agents/hooks/core/prompt-input.ts | 12 +- .agents/hooks/core/refactor-guard.ts | 52 +- .agents/hooks/core/scm-guard.ts | 46 +- .agents/hooks/core/session-summary.ts | 192 +++++++ .agents/hooks/core/skill-injector.ts | 143 +++-- .agents/hooks/core/state-boundary.ts | 40 +- .agents/hooks/core/state-core.ts | 18 +- .agents/hooks/core/state-emit.ts | 24 +- .agents/hooks/core/test-filter.ts | 45 +- .agents/hooks/core/triggers.json | 78 ++- .agents/hooks/core/types.ts | 18 +- .agents/hooks/core/vendor-detect.ts | 80 ++- .agents/hooks/core/vendor-renderer.ts | 105 +++- .agents/hooks/variants/antigravity.json | 2 +- .agents/hooks/variants/claude.json | 2 +- .agents/hooks/variants/codex.json | 2 +- .agents/hooks/variants/commandcode.json | 2 +- .agents/hooks/variants/cursor.json | 2 +- .agents/hooks/variants/grok.json | 2 +- .agents/hooks/variants/kimi.json | 2 +- .agents/hooks/variants/kiro.json | 2 +- .agents/hooks/variants/opencode/oma.ts | 11 +- .agents/hooks/variants/pi/index.ts | 24 +- .agents/hooks/variants/qwen.json | 2 +- .../skills/_shared/core/code-intelligence.md | 10 +- .agents/skills/_shared/core/test-approach.md | 2 +- .agents/skills/_shared/runtime/event-spec.md | 68 ++- .../runtime/execution-protocols/opencode.md | 2 +- .../skills/_shared/runtime/result-contract.md | 11 + .agents/skills/_version.json | 4 +- .agents/skills/oma-academic-writing/SKILL.md | 18 +- .../resources/sentence-structure-reference.md | 21 +- .agents/skills/oma-architecture/SKILL.md | 14 +- .../resources/execution-protocol.md | 10 +- .agents/skills/oma-backend/SKILL.md | 15 +- .../oma-backend/resources/error-playbook.md | 10 +- .../resources/execution-protocol.md | 2 +- .agents/skills/oma-brainstorm/SKILL.md | 30 +- .agents/skills/oma-coordination/SKILL.md | 25 +- .agents/skills/oma-db/SKILL.md | 22 +- .../oma-db/resources/execution-protocol.md | 3 +- .../skills/oma-db/resources/query-tuning.md | 2 +- .agents/skills/oma-debug/SKILL.md | 11 +- .../resources/bug-report-template.md | 33 +- .../oma-debug/resources/common-patterns.md | 9 +- .../resources/debugging-checklist.md | 2 +- .../oma-debug/resources/execution-protocol.md | 2 + .agents/skills/oma-deepsec/SKILL.md | 24 +- .../oma-deepsec/resources/decision-records.md | 27 + .../skills/oma-deepsec/resources/scanning.md | 4 +- .agents/skills/oma-deepsec/resources/setup.md | 29 +- .../skills/oma-deepsec/resources/triage.md | 5 +- .../oma-design/reference/shader-and-3d.md | 51 +- .../oma-design/resources/anti-patterns.md | 6 +- .../oma-design/resources/asset-strategy.md | 45 +- .../skills/oma-design/resources/checklist.md | 6 +- .../oma-design/resources/design-tokens.md | 8 +- .../oma-design/resources/error-playbook.md | 11 +- .../resources/execution-protocol.md | 13 +- .../oma-design/resources/getdesign-fetcher.md | 41 +- .../resources/prompt-enhancement.md | 239 ++------- .../resources/stitch-integration.md | 207 +++++-- .../resources/affected-checks.py | 55 ++ .../resources/release-coordination.md | 37 +- .../resources/troubleshooting.md | 22 +- .../resources/validation-pipeline.md | 386 +++---------- .agents/skills/oma-docs/SKILL.md | 2 +- .agents/skills/oma-explanation/SKILL.md | 6 +- .../resources/html-contract.md | 6 +- .agents/skills/oma-frontend/SKILL.md | 11 +- .../oma-frontend/resources/error-playbook.md | 10 +- .../resources/execution-protocol.md | 4 +- .../oma-frontend/resources/tech-stack.md | 8 +- .../oma-hwp/resources/flatten-tables.ts | 1 - .agents/skills/oma-image/SKILL.md | 2 +- .../skills/oma-image/resources/checklist.md | 4 +- .../oma-image/resources/execution-protocol.md | 2 +- .../skills/oma-image/resources/invocation.md | 2 +- .../oma-image/resources/vendor-matrix.md | 4 +- .../resources/execution-protocol.md | 9 +- .../resources/frameworks/porters-5f.md | 10 +- .../oma-market/resources/output-laws.md | 5 +- .agents/skills/oma-mobile/SKILL.md | 19 +- .../skills/oma-mobile/resources/checklist.md | 6 +- .../oma-mobile/resources/error-playbook.md | 8 +- .../resources/execution-protocol.md | 4 +- .../oma-mobile/resources/screen-template.dart | 28 +- .../resources/screen-template.swift | 36 +- .../oma-mobile/resources/screen-template.tsx | 2 + .agents/skills/oma-mobile/variants/README.md | 2 +- .../variants/flutter/api-template.dart | 40 +- .../oma-mobile/variants/flutter/snippets.md | 113 ++-- .../variants/react-native/api-template.ts | 252 ++++++--- .../variants/react-native/snippets.md | 289 ++++++---- .../variants/react-native/tech-stack.md | 6 +- .../oma-mobile/variants/stack.schema.json | 2 +- .../oma-mobile/variants/swift-ios/snippets.md | 52 +- .../resources/anti-patterns.md | 28 +- .../resources/boundaries/cross-application.md | 32 +- .../resources/boundaries/multi-tenant.md | 122 ++--- .../resources/boundaries/release.md | 2 +- .../resources/boundaries/slo.md | 43 +- .../oma-observability/resources/checklist.md | 10 +- .../oma-observability/resources/examples.md | 51 +- .../resources/incident-forensics.md | 2 +- .../resources/intent-rules.md | 6 +- .../layers/L7-application/crash-analytics.md | 8 +- .../layers/L7-application/mobile-rum.md | 2 +- .../resources/layers/L7-application/waf.md | 4 +- .../layers/L7-application/web-rum.md | 14 +- .../resources/layers/mesh.md | 2 + .../resources/meta-observability.md | 122 ++--- .../resources/observability-as-code.md | 100 ++-- .../resources/signals/audit.md | 56 +- .../resources/signals/cost.md | 72 ++- .../resources/signals/logs.md | 12 +- .../resources/signals/metrics.md | 50 +- .../resources/signals/privacy.md | 34 +- .../oma-observability/resources/standards.md | 7 +- .../resources/transport/collector-topology.md | 28 +- .../resources/transport/sampling-recipes.md | 42 +- .../resources/vendor-categories.md | 16 +- .agents/skills/oma-orchestration/SKILL.md | 46 +- .../oma-orchestration/config/cli-config.yaml | 28 +- .../resources/memory-schema.md | 18 +- .../oma-orchestration/scripts/verify.sh | 2 +- .../oma-pdf/resources/execution-protocol.md | 2 +- .agents/skills/oma-pm/SKILL.md | 14 +- .../skills/oma-pm/resources/error-playbook.md | 2 +- .agents/skills/oma-pm/resources/examples.md | 351 ++++++++++-- .../oma-pm/resources/execution-protocol.md | 8 +- .../oma-pm/resources/task-template.json | 3 + .agents/skills/oma-qa/resources/checklist.md | 68 +-- .../skills/oma-qa/resources/error-playbook.md | 28 +- .../oma-qa/resources/execution-protocol.md | 4 +- .agents/skills/oma-recap/SKILL.md | 10 +- .agents/skills/oma-refactor/SKILL.md | 14 +- .../oma-refactor/resources/governance.md | 81 ++- .../skills/oma-refactor/resources/hotspots.py | 44 ++ .../oma-refactor/resources/measurement.md | 28 +- .agents/skills/oma-scholar/SKILL.md | 12 +- .../skills/oma-scholar/resources/checklist.md | 23 +- .../resources/execution-protocol.md | 27 +- .../oma-scholar/resources/sidecar-spec.md | 24 +- .../skills/oma-scm/resources/merge-risk.md | 12 +- .../resources/onboarding-risk-signals.md | 2 +- .../skills/oma-search/resources/checklist.md | 4 +- .../oma-search/resources/error-playbook.md | 20 +- .../skills/oma-search/resources/examples.md | 4 +- .../resources/execution-protocol.md | 4 +- .agents/skills/oma-slide/SKILL.md | 4 +- .../oma-slide/resources/animation-patterns.md | 21 +- .../oma-slide/resources/assets/deck-stage.js | 13 +- .../oma-slide/resources/canva-integration.md | 181 ++----- .../resources/generation-protocol.md | 45 +- .../oma-slide/resources/style-presets.md | 2 +- .../resources/cost-optimization.md | 26 +- .../oma-tf-infra/resources/error-playbook.md | 6 +- .../resources/multi-cloud-examples.md | 3 + .../resources/policy-testing-examples.md | 106 ++-- .../oma-translation/resources/lang/ja.md | 8 +- .../oma-translation/resources/lang/ko.md | 12 +- .../oma-translation/resources/lang/zh.md | 8 +- .agents/skills/oma-video/SKILL.md | 22 +- .../skills/oma-video/resources/checklist.md | 4 +- .../oma-video/resources/execution-protocol.md | 18 +- .../resources/hyperframes-authoring/README.md | 41 ++ .../resources/hyperframes-authoring/demo.md | 11 + .../hyperframes-authoring/explainer.md | 10 + .../resources/hyperframes-authoring/shorts.md | 10 + .../skills/oma-video/resources/mpt/driver.py | 127 ++--- .../skills/oma-video/resources/prompt-tips.md | 2 +- .../oma-video/resources/vendor-matrix.md | 14 +- .../skills/oma-voice/resources/checklist.md | 10 +- .agents/workflows/architecture.md | 10 +- .agents/workflows/brainstorm.md | 20 +- .agents/workflows/debug.md | 10 +- .agents/workflows/deepinit.md | 4 +- .agents/workflows/deepsec.md | 39 +- .agents/workflows/design.md | 2 +- .agents/workflows/docs.md | 6 +- .agents/workflows/orchestrate.md | 28 +- .agents/workflows/plan.md | 9 +- .agents/workflows/ralph.md | 17 +- .agents/workflows/review.md | 8 +- .agents/workflows/scm.md | 10 +- .agents/workflows/ultrawork.md | 26 +- .agents/workflows/video.md | 72 +-- .agents/workflows/work.md | 14 +- .claude/agents/architecture-reviewer.md | 2 +- .claude/agents/backend-engineer.md | 2 +- .claude/agents/db-engineer.md | 2 +- .claude/agents/debug-investigator.md | 2 +- .claude/agents/docs-curator.md | 2 +- .claude/agents/frontend-engineer.md | 2 +- .claude/agents/mobile-engineer.md | 2 +- .claude/agents/pm-planner.md | 2 +- .claude/agents/qa-reviewer.md | 2 +- .claude/agents/refactor-engineer.md | 4 +- .claude/agents/research-explorer.md | 2 +- .claude/agents/tf-infra-engineer.md | 2 +- .claude/hooks/hud.ts | 63 ++- .claude/hooks/oma-hook.sh | 16 +- .claude/settings.json | 2 +- .codex/agents/refactor-engineer.toml | 2 +- .codex/hooks.json | 2 +- .codex/hooks/oma-hook.sh | 16 +- .cursor/agents/refactor-engineer.md | 2 +- .cursor/hooks.json | 2 +- .cursor/hooks/oma-hook.sh | 16 +- .github/prompts/video.prompt.md | 2 +- .opencode/agents/refactor-engineer.md | 2 +- .opencode/plugins/oma/agentmemory-client.ts | 3 + .../plugins/oma/code-intelligence-guard.ts | 42 +- .../plugins/oma/code-intelligence-primer.ts | 100 ++-- .opencode/plugins/oma/event-contract.ts | 61 +++ .opencode/plugins/oma/fs-utils.ts | 63 ++- .opencode/plugins/oma/hud.ts | 63 ++- .opencode/plugins/oma/keyword-detector.ts | 313 +++++++---- .opencode/plugins/oma/oma.ts | 11 +- .opencode/plugins/oma/persistent-mode.ts | 507 +++++++++++++++--- .opencode/plugins/oma/prompt-input.ts | 12 +- .opencode/plugins/oma/refactor-guard.ts | 52 +- .opencode/plugins/oma/scm-guard.ts | 46 +- .opencode/plugins/oma/session-summary.ts | 192 +++++++ .opencode/plugins/oma/skill-injector.ts | 143 +++-- .opencode/plugins/oma/state-boundary.ts | 40 +- .opencode/plugins/oma/state-core.ts | 18 +- .opencode/plugins/oma/state-emit.ts | 24 +- .opencode/plugins/oma/test-filter.ts | 45 +- .opencode/plugins/oma/triggers.json | 78 ++- .opencode/plugins/oma/types.ts | 18 +- .opencode/plugins/oma/vendor-detect.ts | 80 ++- .opencode/plugins/oma/vendor-renderer.ts | 105 +++- .qwen/agents/refactor-engineer.md | 2 +- .qwen/hooks/hud.ts | 63 ++- .qwen/hooks/oma-hook.sh | 6 +- .qwen/settings.json | 2 +- .serena/project.yml | 140 ++--- AGENTS.md | 2 +- 253 files changed, 6109 insertions(+), 3678 deletions(-) create mode 100644 .agents/hooks/core/event-contract.ts create mode 100644 .agents/hooks/core/session-summary.ts create mode 100644 .agents/skills/oma-deepsec/resources/decision-records.md create mode 100644 .agents/skills/oma-dev-workflow/resources/affected-checks.py create mode 100644 .agents/skills/oma-refactor/resources/hotspots.py create mode 100644 .agents/skills/oma-video/resources/hyperframes-authoring/README.md create mode 100644 .agents/skills/oma-video/resources/hyperframes-authoring/demo.md create mode 100644 .agents/skills/oma-video/resources/hyperframes-authoring/explainer.md create mode 100644 .agents/skills/oma-video/resources/hyperframes-authoring/shorts.md create mode 100644 .opencode/plugins/oma/event-contract.ts create mode 100644 .opencode/plugins/oma/session-summary.ts diff --git a/.agents/agents/refactor-engineer.md b/.agents/agents/refactor-engineer.md index 7dc4430f..945eba47 100644 --- a/.agents/agents/refactor-engineer.md +++ b/.agents/agents/refactor-engineer.md @@ -20,7 +20,7 @@ Follow the shared execution policy for authorization and clarification. State ma 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). diff --git a/.agents/agents/variants/claude.json b/.agents/agents/variants/claude.json index dd3f8ffd..d9221880 100644 --- a/.agents/agents/variants/claude.json +++ b/.agents/agents/variants/claude.json @@ -3,44 +3,24 @@ "vendor": "claude", "destDir": ".claude/agents", "modelDefault": "sonnet", - "maxTurnsDefault": 30, + "maxTurnsDefault": 100, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/claude.md", "agents": { - "backend-engineer": { - "maxTurns": 40 - }, - "frontend-engineer": { - "maxTurns": 40 - }, - "db-engineer": { - "maxTurns": 25 - }, - "debug-investigator": { - "maxTurns": 25 - }, "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": 30 - }, - "mobile-engineer": { - "maxTurns": 40 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, "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/commandcode.json b/.agents/agents/variants/commandcode.json index 3c00acaa..fc6a9869 100644 --- a/.agents/agents/variants/commandcode.json +++ b/.agents/agents/variants/commandcode.json @@ -3,44 +3,24 @@ "vendor": "commandcode", "destDir": ".commandcode/agents", "modelDefault": "inherit", - "maxTurnsDefault": 30, + "maxTurnsDefault": 100, "toolsDefault": "Read, Write, Edit, Bash, Grep, Glob", "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/commandcode.md", "agents": { - "backend-engineer": { - "maxTurns": 40 - }, - "frontend-engineer": { - "maxTurns": 40 - }, - "db-engineer": { - "maxTurns": 25 - }, - "debug-investigator": { - "maxTurns": 25 - }, "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": 30 - }, - "mobile-engineer": { - "maxTurns": 40 + "tools": "Read, Write, Edit, Bash, Grep, Glob" }, "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 bf67720f..fa207b8b 100644 --- a/.agents/agents/variants/grok.json +++ b/.agents/agents/variants/grok.json @@ -3,39 +3,8 @@ "vendor": "grok", "destDir": ".grok/agents", "modelDefault": "grok-build", - "maxTurnsDefault": 30, + "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": { - "maxTurns": 40 - }, - "frontend-engineer": { - "maxTurns": 40 - }, - "db-engineer": { - "maxTurns": 25 - }, - "debug-investigator": { - "maxTurns": 25 - }, - "architecture-reviewer": { - "maxTurns": 15 - }, - "tf-infra-engineer": { - "maxTurns": 30 - }, - "mobile-engineer": { - "maxTurns": 40 - }, - "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 fab204c6..216faff0 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": 30, + "maxTurnsDefault": 100, "toolsDefault": [ "read", "write", @@ -15,38 +15,14 @@ ], "protocolPath": ".agents/skills/_shared/runtime/execution-protocols/kiro.md", "agents": { - "backend-engineer": { - "maxTurns": 40 - }, - "frontend-engineer": { - "maxTurns": 40 - }, - "db-engineer": { - "maxTurns": 25 - }, - "debug-investigator": { - "maxTurns": 25 - }, "architecture-reviewer": { - "tools": ["read", "grep", "glob", "code"], - "maxTurns": 15 - }, - "tf-infra-engineer": { - "maxTurns": 30 - }, - "mobile-engineer": { - "maxTurns": 40 + "tools": ["read", "grep", "glob", "code"] }, "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/hooks/core/agentmemory-client.ts b/.agents/hooks/core/agentmemory-client.ts index 65011c86..decf6467 100644 --- a/.agents/hooks/core/agentmemory-client.ts +++ b/.agents/hooks/core/agentmemory-client.ts @@ -279,6 +279,9 @@ export async function observeWithTimeout(payload: { 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; diff --git a/.agents/hooks/core/code-intelligence-guard.ts b/.agents/hooks/core/code-intelligence-guard.ts index 9c541281..26e585c3 100644 --- a/.agents/hooks/core/code-intelligence-guard.ts +++ b/.agents/hooks/core/code-intelligence-guard.ts @@ -15,11 +15,14 @@ // 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, so -// the deny reason names the escape hatch. -// - Escape hatch: a shell command containing `OMA_CI_ALLOW_NATIVE=1` bypasses -// the guard (mirrors scm-guard's `OMA_SCM_ALLOW_SECRETS=1`). Grep/Glob have -// no argument to carry a token, so the fallback for those is the shell path. +// `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"; @@ -342,9 +345,10 @@ function denyReason( `[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. ` + - `Searches scoped entirely to confirmed provider exclusions or external paths are allowed. ` + - `If ${label} is unavailable, timed out, or cannot search the requested path (including unrecognized exclusions), run the search through the shell tool ` + - `with the command prefixed by ${BYPASS_TOKEN}. ` + + `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.` ); } @@ -358,11 +362,15 @@ function denyReason( */ export async function run( input: HookInput, - _ctx: HandlerCtx, + ctx: HandlerCtx, ): Promise { if (input.kind !== "pre_tool") return null; - const { toolName, toolInput, cwd: projectDir } = input; + 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); @@ -380,23 +388,25 @@ export async function run( // Config reads happen after the cheap tool/command checks so the common // (non-search) path never touches the filesystem. - const provider = detectCodeIntelligenceProvider(projectDir); + const provider = detectCodeIntelligenceProvider(projectDir, ctx.config); if (!provider) return null; - if (detectCodeIntelligenceGuardMode(projectDir) === "off") return null; + if (detectCodeIntelligenceGuardMode(projectDir, ctx.config) === "off") { + return null; + } let roots: string[] | null = null; if (isShell) { - roots = shellSearchRoots(toolInput.command as string, projectDir); + roots = shellSearchRoots(toolInput.command as string, sessionCwd); } else if (isGlob && typeof toolInput.pattern === "string") { const base = - typeof toolInput.path === "string" ? toolInput.path : projectDir; + typeof toolInput.path === "string" ? toolInput.path : sessionCwd; const target = isAbsolute(toolInput.pattern) ? toolInput.pattern - : `${resolve(projectDir, base)}/${toolInput.pattern}`; + : `${resolve(sessionCwd, base)}/${toolInput.pattern}`; const root = searchPathRoot(target); if (root) roots = [root]; } else if (typeof toolInput.path === "string") { - roots = [toolInput.path]; + roots = [resolve(sessionCwd, toolInput.path)]; } if (roots && isExcludedSearchScope(provider, projectDir, roots)) return null; diff --git a/.agents/hooks/core/code-intelligence-primer.ts b/.agents/hooks/core/code-intelligence-primer.ts index 0c5df0c5..c22980df 100644 --- a/.agents/hooks/core/code-intelligence-primer.ts +++ b/.agents/hooks/core/code-intelligence-primer.ts @@ -20,11 +20,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; +import { agyConversationId, readAgyPrompt } from "./agy-input.ts"; import { makePromptOutput } from "./hook-output.ts"; import { normalizePromptInput } from "./prompt-input.ts"; -import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; -import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; +import type { + HandlerCtx, + HandlerResult, + HookConfig, + HookInput, +} from "./types.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; const SESSION_TTL_MS = 60 * 60 * 1000; @@ -69,25 +74,49 @@ function readCodeIntelligenceFromYaml( 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. - * Looks in oma-config.local.yaml, oma-config.yaml, and falls back to + * 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 { - 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 (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 + } } } } @@ -114,7 +143,12 @@ export type CodeIntelligenceGuardMode = "block" | "off"; */ 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"), @@ -218,7 +252,7 @@ export function primerContext( "[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. If Gortex is unavailable, times out, or cannot search the requested path, use native tools: prefix the shell search command with `OMA_CI_ALLOW_NATIVE=1`.", + "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 [ @@ -226,7 +260,7 @@ export function primerContext( "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, times out, or cannot search the requested path, use native tools: prefix the shell search command with `OMA_CI_ALLOW_NATIVE=1`. Do not retry timed-out MCP calls this session.", + "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"); } @@ -235,7 +269,7 @@ export function primerContext( /** * 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 git-root project directory. + * `ctx.cwd` must be the resolved OMA project root. */ export async function run( input: HookInput, @@ -245,7 +279,7 @@ export async function run( const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; - const provider = detectCodeIntelligenceProvider(projectDir); + const provider = detectCodeIntelligenceProvider(projectDir, ctx.config); if (!provider) return null; // Compaction keeps the session id, so the session-once claim would skip @@ -258,32 +292,6 @@ export async function run( // ── Standalone entry (pi subprocess / direct bun invocation) ── -function detectVendor(input: Record): Vendor { - const byScriptPath = inferVendorFromScriptPath(import.meta.filename); - 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 === "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 getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -302,7 +310,7 @@ export async function runStandAlone() { 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 = normalizePromptInput(input.prompt); diff --git a/.agents/hooks/core/event-contract.ts b/.agents/hooks/core/event-contract.ts new file mode 100644 index 00000000..005d2788 --- /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/fs-utils.ts b/.agents/hooks/core/fs-utils.ts index af3acb7a..e5676d54 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/hud.ts b/.agents/hooks/core/hud.ts index 4ec9fed6..9e835aad 100644 --- a/.agents/hooks/core/hud.ts +++ b/.agents/hooks/core/hud.ts @@ -39,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; @@ -136,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?: { @@ -179,8 +204,8 @@ export function shortModel(model?: { /gemini-([\d.]+)-(pro|flash|ultra|nano|thinking)/i, ); if (geminiSlug) { - const capType = - geminiSlug[2].charAt(0).toUpperCase() + geminiSlug[2].slice(1); + 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) || ""; @@ -293,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}`)); } @@ -325,4 +350,6 @@ async function main() { process.stdout.write(out, () => process.exit(0)); } -void main(); +if (import.meta.main) { + void main(); +} diff --git a/.agents/hooks/core/keyword-detector.ts b/.agents/hooks/core/keyword-detector.ts index c69b9c6f..f5b15209 100644 --- a/.agents/hooks/core/keyword-detector.ts +++ b/.agents/hooks/core/keyword-detector.ts @@ -21,7 +21,7 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.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"; @@ -34,9 +34,8 @@ import type { HandlerResult, HookInput, ModeState, - Vendor, } from "./types.ts"; -import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; // ── Unicode normalization ───────────────────────────────────── @@ -273,40 +272,6 @@ export function recordKwTrigger( // ── Vendor Detection ────────────────────────────────────────── -function detectVendor(input: Record): Vendor { - const event = input.hook_event_name as string | undefined; - const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(import.meta.filename); - 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 === "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 getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -318,37 +283,32 @@ function getSessionId(input: Record): string { // ── 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; -} - -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"; - } -} +/** + * 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 ─────────────────────────────────────────── @@ -401,27 +361,35 @@ export interface KeywordPatternEntry { keyword: string; } +/** + * 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 buildPatternEntries( keywords: Record, - lang: string, - cjkScripts: string[], ): KeywordPatternEntry[] { - return collectLangEntries(keywords).map((kw) => { - const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); - const regex = - cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw) - ? new RegExp(escaped, "i") - : new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); - return { regex, keyword: kw }; - }); + return collectLangEntries(keywords).map((kw) => ({ + regex: compileKeyword(kw), + keyword: kw, + })); } -export function buildPatterns( - keywords: Record, - lang: string, - cjkScripts: string[], -): RegExp[] { - return buildPatternEntries(keywords, lang, cjkScripts).map((e) => e.regex); +export function buildPatterns(keywords: Record): RegExp[] { + return buildPatternEntries(keywords).map((e) => e.regex); } /** @@ -629,13 +597,16 @@ function isInterrogativeSentence(line: string): boolean { return /\?\s*$/.test(line) && INTERROGATIVE_WORD.test(line); } -export function isAnalyticalQuestion(prompt: string): boolean { +function firstAndLastLines(prompt: string): [string, string] { const lines = prompt .split("\n") .map((l) => l.trim()) .filter(Boolean); - const firstLine = lines[0] ?? ""; - const lastLine = lines[lines.length - 1] ?? ""; + return [lines[0] ?? "", lines[lines.length - 1] ?? ""]; +} + +export function isAnalyticalQuestion(prompt: string): boolean { + const [firstLine, lastLine] = firstAndLastLines(prompt); return ( isInterrogativeSentence(firstLine) || isInterrogativeSentence(lastLine) || @@ -643,6 +614,18 @@ export function isAnalyticalQuestion(prompt: string): boolean { ); } +/** + * 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) @@ -892,8 +875,11 @@ export interface WorkflowCandidate { declarationIndex: number; /** True if any suppression filter (RC3 technical-reference is a hard drop * and never reaches this point; informational-context / pasted-content / - * reinforcement) applies to this specific match. */ + * 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; } /** @@ -901,6 +887,9 @@ export interface WorkflowCandidate { * 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. @@ -938,6 +927,9 @@ export function pickWinningCandidate( 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; } @@ -952,6 +944,85 @@ export function pickWinningCandidate( 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) ───────────────────────────── /** @@ -982,8 +1053,7 @@ export async function run( // 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)) { @@ -991,19 +1061,22 @@ export async function run( 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); + // 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). @@ -1021,17 +1094,16 @@ export async function run( // ranking (see pickWinningCandidate) decides the winner, replacing the old // declaration-order first-match-wins loop. const candidates: WorkflowCandidate[] = []; - const workflowEntries = Object.entries(config.workflows); - for ( - let declarationIndex = 0; - declarationIndex < workflowEntries.length; - declarationIndex++ - ) { - const entry = workflowEntries[declarationIndex]; - if (!entry) continue; - const [workflow, def] = entry; - if (excluded.has(workflow)) continue; + 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; @@ -1040,7 +1112,11 @@ export async function run( const reinforced = isReinforcementSuppressed(kwState, workflow); - const considerMatch = (regex: RegExp, specificityText: string) => { + const considerMatch = ( + regex: RegExp, + specificityText: string, + explicit: boolean, + ) => { const match = regex.exec(cleaned); if (!match) return; // RC3: compound technical tokens (ralph:verify, ralph.md, @@ -1063,6 +1139,9 @@ export async function run( 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, @@ -1073,19 +1152,20 @@ export async function run( keywordLength: text.length, isMultiWord: /\s/.test(text), declarationIndex, - suppressed: informational || pasted || reinforced, + suppressed: informational || pasted || reinforced || questionGated, + explicit, }); }; - for (const { regex, keyword } of buildPatternEntries( - def.keywords, - lang, - config.cjkScripts, - )) { - considerMatch(regex, keyword); + for (const { regex, keyword } of keywordEntries) { + considerMatch( + regex, + keyword, + explicitKeywords.has(keyword.toLowerCase()), + ); } - for (const { regex, source } of buildRawPatternEntries(def.patterns)) { - considerMatch(regex, source); + for (const { regex, source } of rawEntries) { + considerMatch(regex, source, false); } } @@ -1093,6 +1173,7 @@ export async function run( 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). @@ -1102,18 +1183,20 @@ export async function run( vendor, sessionId, ); - if (winner.persistent) { + // 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 = [ - `[OMA WORKFLOW: ${workflow.toUpperCase()}]`, - `User intent matches the /${workflow} workflow.`, - `Read and follow \`.agents/workflows/${workflow}.md\` step by step.`, - `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, - ]; + const contextLines = buildWorkflowContext(workflow, { + persistent: winner.persistent, + explicit, + explicitExample: config.workflows[workflow]?.explicit?.[0], + }); if (config.extensionRouting) { const extensions = detectExtensions(prompt); @@ -1143,7 +1226,7 @@ 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 = normalizePromptInput(input.prompt); diff --git a/.agents/hooks/core/persistent-mode.ts b/.agents/hooks/core/persistent-mode.ts index 4fcca0cc..84182e7e 100644 --- a/.agents/hooks/core/persistent-mode.ts +++ b/.agents/hooks/core/persistent-mode.ts @@ -13,16 +13,17 @@ * exit 2 = block stop */ -import { spawnSync } from "node:child_process"; +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, isAgyInput } from "./agy-input.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"; @@ -36,11 +37,18 @@ import type { ModeState, Vendor, } from "./types.ts"; -import { getProjectDir } from "./vendor-detect.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; const MAX_REINFORCEMENTS = 5; const STALE_HOURS = 2; +/** + * 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) @@ -53,12 +61,54 @@ const STALE_HOURS = 2; */ const GATE_KEYWORDS = new Set(["typecheck", "test", "lint"]); -/** Hard cap on a gate run; SIGKILL after this. Keeps Stop-hook latency bounded. */ -const GATE_TIMEOUT_MS = 60_000; +/** + * 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 @@ -100,28 +150,134 @@ export interface GateRunResult { outputTail: string; } -/** Run a resolved gate argv with a hard timeout. No shell involved. */ +/** + * 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, -): GateRunResult { + timeoutMs: number = gateTimeoutMs(), +): Promise { const [command, ...args] = argv; - const result = spawnSync(command as string, args, { - cwd: projectDir, - encoding: "utf-8", - timeout: GATE_TIMEOUT_MS, - killSignal: "SIGKILL", - maxBuffer: 8 * 1024 * 1024, + 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(); + }); }); - const timedOut = - (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT" || - result.signal === "SIGKILL"; - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - return { - passed: result.status === 0 && !result.error, - timedOut, - outputTail: combined.slice(-GATE_OUTPUT_TAIL_CHARS), - }; } /** True when the goal's wall-clock budget (from activatedAt) is exhausted. */ @@ -161,6 +317,103 @@ async function emitGateEvent( } } +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. + } + } +} + // ── Config Loading ──────────────────────────────────────────── interface TriggerConfig { @@ -180,33 +433,6 @@ 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 === "Stop") { - if ("session_id" in input && !("sessionId" in input)) return "codex"; - } - if (process.env.QWEN_PROJECT_DIR) return "qwen"; - return "claude"; -} - function getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -226,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; } @@ -273,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, @@ -296,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 run`, 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. @@ -314,6 +593,15 @@ export async function run( 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 @@ -328,30 +616,67 @@ export async function run( // mode could not be deactivated via the central `oma hook run` dispatch. if (input.responseText) { 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 (isStale(state) || state.reinforcementCount >= MAX_REINFORCEMENTS) { + 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 → honest partial stop. A machine + // (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", - summary: `wall-clock budget (${state.goal?.budget?.wallClockMinutes}m) exhausted for /${workflow}; stopping with partial status`, + 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; } @@ -367,7 +692,24 @@ export async function run( if (gateKeyword) { const argv = resolveGateArgv(gateKeyword, projectDir); if (argv) { - const gate = runGateCommand(argv, projectDir); + 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); @@ -375,20 +717,38 @@ export async function run( 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 gate cannot block stops forever. + // 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, - summary: `stop gate '${gateKeyword}' ${gate.timedOut ? "timed out" : "failed"} for /${workflow}`, + ...(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()}]`, - `Stop gate '${gateKeyword}' ${gate.timedOut ? `timed out after ${GATE_TIMEOUT_MS / 1000}s` : "FAILED"} (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).`, - `Fix the failures below, then finish the workflow — the stop is allowed only when the gate passes.`, + 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}` : "", @@ -396,6 +756,7 @@ export async function run( ] .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.`; @@ -413,9 +774,11 @@ export async function run( .filter(Boolean) .join("\n"); + await finishPersistentSessions(projectDir, sessionId, endings); return { type: "block", reason }; } + await finishPersistentSessions(projectDir, sessionId, endings); return null; } @@ -430,7 +793,7 @@ 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); @@ -449,14 +812,12 @@ async function main() { .filter((v): v is string => typeof v === "string") .join(" "); - if (textToCheck && isDeactivationRequest(textToCheck)) { - // 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 index 7f185805..f949f0ff 100644 --- a/.agents/hooks/core/prompt-input.ts +++ b/.agents/hooks/core/prompt-input.ts @@ -17,13 +17,23 @@ const RELAY_ENVELOPE_PREFIXES = [ " trimmed.startsWith(p))) return true; - return trimmed.slice(0, 200).includes('"type":"idle_notification"'); + 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. */ diff --git a/.agents/hooks/core/refactor-guard.ts b/.agents/hooks/core/refactor-guard.ts index 7ee813c4..76bfa159 100644 --- a/.agents/hooks/core/refactor-guard.ts +++ b/.agents/hooks/core/refactor-guard.ts @@ -39,7 +39,13 @@ 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, HookInput, Vendor } from "./types.ts"; +import type { + HandlerCtx, + HandlerResult, + HookConfig, + HookInput, + Vendor, +} from "./types.ts"; import { getProjectDir } from "./vendor-detect.ts"; // --- Defaults --- @@ -152,11 +158,39 @@ function scalarValue(raw: string): string { .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): GuardConfig { +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 }; @@ -303,12 +337,16 @@ function recordTouched( input: HookInput & { kind: "post_tool" }, ctx: HandlerCtx, ): null { - const { toolName, toolInput, cwd: projectDir } = input; + 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); + const config = loadGuardConfig(projectDir, ctx.config); if (!config.enabled) return null; const sid = ctx.sid ?? "unknown"; @@ -316,7 +354,7 @@ function recordTouched( for (const rawPath of resolveEditedPaths(toolName, toolInput)) { const absPath = isAbsolute(rawPath) ? rawPath - : resolve(projectDir, rawPath); + : resolve(sessionCwd, rawPath); const relPath = toPosixPath(relative(projectDir, absPath)); if (!isRefactorableFile(relPath)) continue; if (!existsSync(absPath)) continue; @@ -344,10 +382,10 @@ function enforceOnStop( input: HookInput & { kind: "stop" }, ctx: HandlerCtx, ): HandlerResult | null { - const projectDir = input.cwd; + const projectDir = ctx.cwd || input.cwd; if (!projectDir) return null; - const config = loadGuardConfig(projectDir); + const config = loadGuardConfig(projectDir, ctx.config); if (!config.enabled) return null; const sid = ctx.sid ?? "unknown"; diff --git a/.agents/hooks/core/scm-guard.ts b/.agents/hooks/core/scm-guard.ts index b5b03341..4a767a25 100644 --- a/.agents/hooks/core/scm-guard.ts +++ b/.agents/hooks/core/scm-guard.ts @@ -17,7 +17,13 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { makePreToolDenyOutput } from "./hook-output.ts"; -import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.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) --- @@ -86,9 +92,35 @@ interface GuardConfig { exceptions: string[]; } -function loadGuardConfig(projectDir: string): GuardConfig { +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 = [ - join(projectDir, MAIN_CONFIG_RELPATH), + ...(config ? [] : [join(projectDir, MAIN_CONFIG_RELPATH)]), join(projectDir, SKILL_CONFIG_RELPATH), ]; @@ -174,11 +206,13 @@ export function extractGitAddPaths(command: string): string[] { */ export async function run( input: HookInput, - _ctx: HandlerCtx, + ctx: HandlerCtx, ): Promise { if (input.kind !== "pre_tool") return null; - const { toolName, toolInput, cwd: projectDir } = input; + 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" && @@ -196,7 +230,7 @@ export async function run( const candidates = extractGitAddPaths(command); if (candidates.length === 0) return null; - const config = loadGuardConfig(projectDir); + const config = loadGuardConfig(projectDir, ctx.config); const flagged = candidates.filter((p) => matchesForbidden(p, config)); if (flagged.length === 0) return null; diff --git a/.agents/hooks/core/session-summary.ts b/.agents/hooks/core/session-summary.ts new file mode 100644 index 00000000..0b0d4a88 --- /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 b48a4f62..42b4982f 100644 --- a/.agents/hooks/core/skill-injector.ts +++ b/.agents/hooks/core/skill-injector.ts @@ -21,52 +21,21 @@ import { writeFileSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; -import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.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 { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.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 detectVendor(input: Record): Vendor { - const event = input.hook_event_name as string | undefined; - const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(import.meta.filename); - 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 === "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 getSessionId(input: Record): string { return ( (input.sessionId as string) || @@ -80,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 ────────────────────────────────────────── @@ -113,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"); @@ -175,19 +127,33 @@ 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 @@ -201,16 +167,33 @@ export function matchSkills( ]; 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 (allTriggers.length === 0) continue; + if (triggers.length > 0) { + compiled = { triggers, patterns: buildTriggerPatterns(triggers) }; + } + } + perConfig.set(skillName, compiled); + return compiled; +} + +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 patterns = buildTriggerPatterns(allTriggers, lang, cjkScripts); + const { triggers: allTriggers, patterns } = compiled; const matched: string[] = []; let score = 0; @@ -490,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( @@ -520,7 +501,7 @@ 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 = normalizePromptInput(input.prompt); diff --git a/.agents/hooks/core/state-boundary.ts b/.agents/hooks/core/state-boundary.ts index 2995aa72..20aaee8c 100644 --- a/.agents/hooks/core/state-boundary.ts +++ b/.agents/hooks/core/state-boundary.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { recallFacts } from "./agentmemory-client.ts"; -import { agyConversationId, isAgyInput } from "./agy-input.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"; @@ -10,43 +10,9 @@ import { normalizePromptInput } from "./prompt-input.ts"; import { emitEvent, type OmaEvent, readEvents } from "./state-emit.ts"; import { getActiveSid, readIndex, setLastSession } from "./state-marker.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; -import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; +import { detectVendorFromInput, getProjectDir } from "./vendor-detect.ts"; import { type MemoryFact, renderStateSnapshot } from "./vendor-renderer.ts"; -function detectVendor(input: Record): Vendor { - const event = input.hook_event_name as string | undefined; - const hookEventName = input.hookEventName as string | undefined; - const byScriptPath = inferVendorFromScriptPath(import.meta.filename); - 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 === "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 getVendorSid(input: Record): string { return ( (input.sessionId as string) || @@ -222,7 +188,7 @@ 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); diff --git a/.agents/hooks/core/state-core.ts b/.agents/hooks/core/state-core.ts index 2b7fc901..1eb493f7 100644 --- a/.agents/hooks/core/state-core.ts +++ b/.agents/hooks/core/state-core.ts @@ -12,6 +12,7 @@ import { writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { validateEventPayload } from "./event-contract.ts"; import { ensureProfile, ensureSessionStorage, @@ -252,6 +253,12 @@ function appendEvent( 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(), @@ -284,10 +291,19 @@ function appendEvent( 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; } @@ -347,7 +363,7 @@ export function deriveMeta(sid: string, events: OmaEvent[]): SessionMeta { meta.gatesPassedBy.push({ ts: event.ts, ...(event.payload ?? {}) }); } else if (event.kind === "session.ended") { const status = event.payload?.status; - meta.status = status === "failed" ? "failed" : "completed"; + if (status === "failed" || status === "completed") meta.status = status; } } return meta; diff --git a/.agents/hooks/core/state-emit.ts b/.agents/hooks/core/state-emit.ts index 5215f604..4343714e 100644 --- a/.agents/hooks/core/state-emit.ts +++ b/.agents/hooks/core/state-emit.ts @@ -1,4 +1,4 @@ -import { appendFileSync } from "node:fs"; +import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs"; import { observeWithTimeout } from "./agentmemory-client.ts"; import { emitEvent as appendEvent, @@ -36,8 +36,26 @@ export async function emitEvent( }); if (!observed) { const path = retryObservePath(projectDir); - ensureParent(path); - appendFileSync(path, `${JSON.stringify(enriched)}\n`, "utf-8"); + 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`); + } } } return enriched; diff --git a/.agents/hooks/core/test-filter.ts b/.agents/hooks/core/test-filter.ts index 1ab21637..c5ea37b0 100644 --- a/.agents/hooks/core/test-filter.ts +++ b/.agents/hooks/core/test-filter.ts @@ -4,31 +4,12 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { makePreToolOutput } from "./hook-output.ts"; -import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; -import { getHookDir, getProjectDir } from "./vendor-detect.ts"; - -// --- Vendor detection (same logic as keyword-detector.ts) --- - -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 === "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"; -} +import type { HandlerCtx, HandlerResult, HookInput } from "./types.ts"; +import { + detectVendorFromInput, + getHookDir, + getProjectDir, +} from "./vendor-detect.ts"; // --- Test runner patterns --- @@ -92,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, @@ -100,8 +81,11 @@ 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; + // 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 @@ -146,7 +130,10 @@ export async function run( .find((p) => existsSync(p)); if (!filterScript) return null; - const filteredCmd = `set -o pipefail; (${command}) 2>&1 | bash "${filterScript}"`; + // 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, @@ -171,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 0351ae68..cc49fbbe 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,8 +830,9 @@ }, "work": { "persistent": true, + "explicit": ["work mode", "work workflow"], "keywords": { - "*": ["work", "step by step"], + "*": ["step by step", "work mode", "work workflow"], "en": ["one by one", "one step at a time"], "ko": [ "단계별", @@ -1187,6 +1190,7 @@ }, "ralph": { "persistent": true, + "explicit": ["ralph", "랄프"], "keywords": { "*": ["ralph"], "en": [ @@ -1194,7 +1198,7 @@ "do not stop", "must complete", "until done", - "keep going", + "keep going until", "finish everything", "complete everything", "run to completion", @@ -1202,7 +1206,6 @@ "see it through", "don't give up", "never stop", - "carry on", "push through", "finish it all", "complete all" @@ -1221,8 +1224,7 @@ "끝장내", "끝까지 해줘", "다 끝내줘", - "계속해", - "계속 해줘", + "때까지 계속", "그만두지마", "포기하지마", "멈추지 말아줘" @@ -1236,7 +1238,6 @@ "完了するまで", "最後までやって", "諦めないで", - "続けて", "中断しないで", "全部やりきって" ], @@ -1249,7 +1250,6 @@ "做完为止", "一直做到完", "别放弃", - "继续做", "不要中断", "全部做完" ], @@ -1261,7 +1261,6 @@ "no te detengas", "completa todo", "no abandones", - "sigue adelante", "acaba todo" ], "fr": [ @@ -1283,7 +1282,6 @@ "nicht aufhören", "mach alles fertig", "gib nicht auf", - "mach weiter", "alles abschließen" ], "pt": [ @@ -1305,7 +1303,6 @@ "не прекращай", "доделай всё", "не сдавайся", - "продолжай", "закончи всё" ], "nl": [ @@ -1316,7 +1313,6 @@ "niet stoppen", "alles afronden", "geef niet op", - "ga door", "maak alles klaar" ], "pl": [ @@ -1327,7 +1323,6 @@ "nie przerywaj", "skończ wszystko", "nie poddawaj się", - "kontynuuj", "zakończ wszystko" ] } @@ -3198,7 +3193,6 @@ ] }, "excludedWorkflows": ["tools", "stack-set", "schedule", "convert"], - "cjkScripts": ["ko", "ja", "zh"], "extensionRouting": { "frontend-engineer": [ "tsx", diff --git a/.agents/hooks/core/types.ts b/.agents/hooks/core/types.ts index 0b0bcf45..ec045b95 100644 --- a/.agents/hooks/core/types.ts +++ b/.agents/hooks/core/types.ts @@ -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"; @@ -138,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 index ff945b2d..ae36413b 100644 --- a/.agents/hooks/core/vendor-detect.ts +++ b/.agents/hooks/core/vendor-detect.ts @@ -1,15 +1,15 @@ // Shared vendor-detection helpers for the core hook handlers. // // Previously each handler (keyword-detector, state-boundary, skill-injector, -// serena-primer, persistent-mode, test-filter) carried its own copy of these -// functions, which drifted (different vendor subsets). They are event- -// independent — vendor → script path, project dir, hook dir — so they live here -// once and every handler imports them. `detectVendor` stays per-handler because -// the event-name → vendor mapping is hook-kind-specific (prompt/tool/stop). +// 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 } from "./agy-input.ts"; -import { resolveGitRoot } from "./fs-utils.ts"; +import { agyProjectDir, isAgyInput } from "./agy-input.ts"; +import { resolveProjectRoot } from "./fs-utils.ts"; import type { Vendor } from "./types.ts"; /** @@ -34,7 +34,69 @@ export function inferVendorFromScriptPath(scriptPath: string): Vendor | null { return null; } -/** Resolve the git-root project directory for a vendor + raw hook input. */ +/** 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, @@ -71,7 +133,7 @@ export function getProjectDir( dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); break; } - return resolveGitRoot(dir); + return resolveProjectRoot(dir); } /** diff --git a/.agents/hooks/core/vendor-renderer.ts b/.agents/hooks/core/vendor-renderer.ts index 8cf9764e..d1471d1d 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"; @@ -18,6 +19,79 @@ export interface StateSnapshotRenderInput { 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[] { const seen = new Set(); return events @@ -27,16 +101,25 @@ function renderRecentEvents(events: OmaEvent[]): string[] { seen.add(event.kind); return true; }) - .map((event) => - event.kind === "boundary" ? "- boundary" : `- ${event.ts} ${event.kind}`, - ); + .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}`; }); } @@ -45,17 +128,19 @@ function renderClaudeSnapshot(input: StateSnapshotRenderInput): string { const events = renderRecentEvents(input.recentEvents); return [ "[OMA STATE SNAPSHOT]", - `sid: ${input.sid}`, - `reason: ${input.reason}`, + `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, + ...input.evolution.slice(0, 6).map((line) => snapshotText(line, 200)), ] : []), - ].join("\n"); + ] + .join("\n") + .slice(0, MAX_SNAPSHOT_CHARS); } export function renderStateSnapshot(input: StateSnapshotRenderInput): string { diff --git a/.agents/hooks/variants/antigravity.json b/.agents/hooks/variants/antigravity.json index 9c31c985..6560d281 100644 --- a/.agents/hooks/variants/antigravity.json +++ b/.agents/hooks/variants/antigravity.json @@ -35,7 +35,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/claude.json b/.agents/hooks/variants/claude.json index ac865ecf..2b1f2d44 100644 --- a/.agents/hooks/variants/claude.json +++ b/.agents/hooks/variants/claude.json @@ -61,7 +61,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/codex.json b/.agents/hooks/variants/codex.json index 65e5150d..58ba2889 100644 --- a/.agents/hooks/variants/codex.json +++ b/.agents/hooks/variants/codex.json @@ -51,7 +51,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/commandcode.json b/.agents/hooks/variants/commandcode.json index 30a87f3f..fea5bfe6 100644 --- a/.agents/hooks/variants/commandcode.json +++ b/.agents/hooks/variants/commandcode.json @@ -26,7 +26,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/cursor.json b/.agents/hooks/variants/cursor.json index e37ebe6f..6fee10a1 100644 --- a/.agents/hooks/variants/cursor.json +++ b/.agents/hooks/variants/cursor.json @@ -51,7 +51,7 @@ "stop": [ { "hook": "persistent-mode.ts", - "timeout": 5, + "timeout": 30, "loopLimit": null }, { diff --git a/.agents/hooks/variants/grok.json b/.agents/hooks/variants/grok.json index 3fdc8b57..2684be0b 100644 --- a/.agents/hooks/variants/grok.json +++ b/.agents/hooks/variants/grok.json @@ -51,7 +51,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/kimi.json b/.agents/hooks/variants/kimi.json index c50b42ad..42046b51 100644 --- a/.agents/hooks/variants/kimi.json +++ b/.agents/hooks/variants/kimi.json @@ -52,7 +52,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/kiro.json b/.agents/hooks/variants/kiro.json index 9e861fa1..024f24b5 100644 --- a/.agents/hooks/variants/kiro.json +++ b/.agents/hooks/variants/kiro.json @@ -52,7 +52,7 @@ "stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/hooks/variants/opencode/oma.ts b/.agents/hooks/variants/opencode/oma.ts index a488524c..c21e80d7 100644 --- a/.agents/hooks/variants/opencode/oma.ts +++ b/.agents/hooks/variants/opencode/oma.ts @@ -61,6 +61,13 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +/** + * persistent-mode may run a goal stop gate (25s cap in persistent-mode.ts). + * Its budget matches the variant Stop handler timeout (30s) so this bridge + * never kills the hook mid-gate — the other core scripts keep 5s. + */ +const PERSISTENT_MODE_TIMEOUT_MS = 30_000; + /** Absolute path to a core script copied next to this bridge at install time. */ function corePath(script: string): string { return fileURLToPath(new URL(`./${script}`, import.meta.url)); @@ -78,13 +85,14 @@ function runCore( script: string, payload: Record, cwd: string, + timeoutMs = 5000, ): Record | null { try { const res = spawnSync("bun", [corePath(script)], { input: JSON.stringify(payload), cwd, encoding: "utf-8", - timeout: 5000, + timeout: timeoutMs, env: process.env, }); const out = (res.stdout ?? "").trim(); @@ -410,6 +418,7 @@ export default (async ({ "persistent-mode.ts", { cwd, sessionId: sessionID, hook_event_name: "Stop" }, cwd, + PERSISTENT_MODE_TIMEOUT_MS, ); if (pm?.decision !== "block") { diff --git a/.agents/hooks/variants/pi/index.ts b/.agents/hooks/variants/pi/index.ts index a18ae181..e99e66e1 100644 --- a/.agents/hooks/variants/pi/index.ts +++ b/.agents/hooks/variants/pi/index.ts @@ -38,6 +38,13 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +/** + * persistent-mode may run a goal stop gate (25s cap in persistent-mode.ts). + * Its budget matches the variant Stop handler timeout (30s) so this bridge + * never kills the hook mid-gate — the other core scripts keep 5s. + */ +const PERSISTENT_MODE_TIMEOUT_MS = 30_000; + /** Absolute path to a core script copied next to this bridge at install time. */ function corePath(script: string): string { return fileURLToPath(new URL(`./${script}`, import.meta.url)); @@ -53,13 +60,14 @@ function corePath(script: string): string { function runCore( script: string, payload: Record, + timeoutMs = 5000, ): Record | null { try { const res = spawnSync("bun", [corePath(script)], { input: JSON.stringify(payload), cwd: process.cwd(), encoding: "utf-8", - timeout: 5000, + timeout: timeoutMs, env: process.env, }); const out = (res.stdout ?? "").trim(); @@ -212,11 +220,15 @@ export default function omaHooks(pi: ExtensionAPI): void { if (typeof pi.sendUserMessage !== "function") return undefined; const sessionId = sessionIdOf(ctx); - const pm = runCore("persistent-mode.ts", { - cwd: process.cwd(), - hook_event_name: "Stop", - ...(sessionId ? { sessionId } : {}), - }); + const pm = runCore( + "persistent-mode.ts", + { + cwd: process.cwd(), + hook_event_name: "Stop", + ...(sessionId ? { sessionId } : {}), + }, + PERSISTENT_MODE_TIMEOUT_MS, + ); // persistent-mode classifies this subprocess as a generic vendor, so // the decision arrives as `{ decision: "block" }`; also accept pi's diff --git a/.agents/hooks/variants/qwen.json b/.agents/hooks/variants/qwen.json index 7d78687f..d7a6dd6d 100644 --- a/.agents/hooks/variants/qwen.json +++ b/.agents/hooks/variants/qwen.json @@ -51,7 +51,7 @@ "Stop": [ { "hook": "persistent-mode.ts", - "timeout": 5 + "timeout": 30 }, { "hook": "refactor-guard.ts", diff --git a/.agents/skills/_shared/core/code-intelligence.md b/.agents/skills/_shared/core/code-intelligence.md index b1b6c231..05915ed0 100644 --- a/.agents/skills/_shared/core/code-intelligence.md +++ b/.agents/skills/_shared/core/code-intelligence.md @@ -13,14 +13,16 @@ requirement in a workflow or skill. `oma install` / `oma update`; if the provider reports the repository as untracked, tell the user to run `oma update`. 4. If the provider is unavailable, has no applicable tool, or times out, use - native search and scoped file reads. Record the fallback and its limits in - the result. + native search only for paths outside this project or ignored paths, and + scoped reads of files already located. Do not search project source with + native tools as a substitute. Record the fallback and its limits in the + result. 5. The `code-intelligence-guard` PreToolUse hook enforces this contract while a provider is configured (`providers.code_intelligence_guard: block`, the default): native `Grep` / `Glob` and recursive shell search (`rg`, `grep -r`, `find -name`, `git grep`) are denied and the reason names the provider tool - to use. In the fallback case of rule 4, prefix the shell search command with - `OMA_CI_ALLOW_NATIVE=1`; that prefix is the recorded fallback signal. + to use. Searches whose entire scope is a confirmed exclusion or outside the + project already pass. The hook does not name a bypass for project source. The fallback is valid evidence when it covers the requested scope; it is not a reason to stop independent work or to silently switch to another provider. diff --git a/.agents/skills/_shared/core/test-approach.md b/.agents/skills/_shared/core/test-approach.md index 288fc21c..fdb86ada 100644 --- a/.agents/skills/_shared/core/test-approach.md +++ b/.agents/skills/_shared/core/test-approach.md @@ -36,7 +36,7 @@ TDD_EVIDENCE: green: 12 pass, 0 fail (after implementation) ``` -Requirements (enforced by `oma verify ` → "TDD Evidence" check): +Requirements (enforced by `oma verify agent ` → "TDD Evidence" check): 1. Block starts with the literal marker `TDD_EVIDENCE:` 2. Every `tdd` task id from the plan appears in the block diff --git a/.agents/skills/_shared/runtime/event-spec.md b/.agents/skills/_shared/runtime/event-spec.md index 5173a1bb..2dbfa8ff 100644 --- a/.agents/skills/_shared/runtime/event-spec.md +++ b/.agents/skills/_shared/runtime/event-spec.md @@ -1,7 +1,11 @@ # OMA L1 Event Spec This file defines the minimum durable event contract for cross-runtime workflow state. -Events are appended to `.agents/state/sessions/{sid}/events.jsonl`. +Events are appended under the selected home profile: +`${OMA_STATE_HOME:-~/.oma}/u/{OMA_PROFILE:-0}/sessions/{sid}/events.jsonl`. +Use `oma state get ` or `oma state list` for inspection rather than assuming +a project-local path. `.agents/state/sessions/` is the legacy migration source; +coordination memories and agent-run receipts retain their separate project paths. ## Common Fields @@ -54,39 +58,32 @@ Readers MUST derive state by sorting valid events by `(ts, eventId)`. Raw file o "eventId": { "type": "string", "minLength": 1 }, "ts": { "type": "string", "format": "date-time" }, "sid": { "type": "string", "minLength": 1 }, - "kind": { - "type": "string", - "enum": [ - "boundary", - "session.created", - "workflow.phase", - "gate.passed", - "gate.failed", - "blocker.raised", - "decision.made", - "decision.missing", - "session.ended" - ] - }, + "kind": { "type": "string", "minLength": 1 }, "writerPid": { "type": "integer" }, "vendor": { "type": "string" }, "vendorSid": { "type": "string" }, "parentEventId": { "type": "string" }, "causalityKey": { "type": "string" }, - "payload": { "type": "object" } + "payload": { + "type": "object", + "properties": { + "instanceId": { "type": "string", "pattern": "\\S" } + } + } }, "allOf": [ { "if": { "properties": { "kind": { "const": "decision.made" } } }, "then": { + "required": ["payload"], "properties": { "payload": { "type": "object", "required": ["subject", "decision", "rationale"], "properties": { - "subject": { "type": "string", "minLength": 1 }, - "decision": { "type": "string", "minLength": 1 }, - "rationale": { "type": "string", "minLength": 1 } + "subject": { "type": "string", "pattern": "\\S" }, + "decision": { "type": "string", "pattern": "\\S" }, + "rationale": { "type": "string", "pattern": "\\S" } } } } @@ -95,6 +92,7 @@ Readers MUST derive state by sorting valid events by `(ts, eventId)`. Raw file o { "if": { "properties": { "kind": { "const": "session.ended" } } }, "then": { + "required": ["payload"], "properties": { "payload": { "type": "object", @@ -105,6 +103,18 @@ Readers MUST derive state by sorting valid events by `(ts, eventId)`. Raw file o } } } + }, + { + "if": { "properties": { "kind": { "const": "blocker.raised" } } }, + "then": { + "required": ["payload"], + "properties": { + "payload": { + "required": ["summary"], + "properties": { "summary": { "type": "string", "pattern": "\\S" } } + } + } + } } ] } @@ -194,8 +204,11 @@ Optional payload fields: - `alternatives` - `evidence` +- `instanceId`: concrete finding, revision, or attempt identifier. Required when the event must satisfy a workflow checkpoint. + +> **Substitute real content.** Fill `decision` and `rationale` with the actual choice and cause. Preserve the fixed `subject`; use the same concrete `instanceId` in the emitted payload and verifier. Empty or whitespace-only decision fields are rejected. An optional decision without `instanceId` is retained but cannot satisfy a required checkpoint. -> **Substitute real content.** Workflow files show `oma state emit` commands as templates. When emitting, fill `decision` and `rationale` with the *actual* decision made in this run — the chosen option, the target file, the approve/skip outcome — never the literal template sentence. Only `subject` is a fixed key (the verifier matches on it); an audit log of identical boilerplate strings records that decisions happened but not what they were. +Use an instance that changes when the reviewed item changes: iteration plus plan revision, a finding ID plus patch/revalidation revision, or a target path plus patch revision. A single session-wide constant allows old decisions to satisfy later work. Keep IDs identical for emit and verify; do not emit literal angle-bracket placeholders. ### `decision.missing` @@ -205,6 +218,7 @@ Required payload fields: - `workflow` - `checkpoint` +- `instanceId` - `missing` - `remediation` @@ -216,6 +230,12 @@ Required payload fields: - `status`: `completed` or `failed`. +Optional payload fields: + +- `reason`: completion cause or failure reason. + +Completion gates and explicit workflow completion end the session as `completed`. Exhausted budgets end it as `failed` with a reason. A failed retryable gate keeps the session active. Terminal events remove matching active-index entries. Persistent-mode termination also exports the coordination summary; other callers can use `oma state summary`. Unsupported terminal statuses are diagnosed and do not mark a session completed. + ## Emitting Decisions Call `oma state emit` **directly** at each required checkpoint — do not wrap it in a shell helper. @@ -225,6 +245,10 @@ Every Bash tool call runs in a fresh shell, so a shell function defined in one c Required decision example: ```bash -oma state emit "decision.made" '{"subject":"ultrawork.plan-approved","decision":"Proceed with the approved plan.","rationale":"PLAN_GATE passed and the user confirmed scope."}' -oma state verify --workflow ultrawork --checkpoint plan-approved +oma state emit "decision.made" '{"subject":"ultrawork.plan-approved","instanceId":"iteration-1:plan-v2","decision":"Use SQLite for the local append-only retry ledger.","rationale":"The approved scope requires cross-process serialization without a hosted service."}' +oma state verify --workflow ultrawork --checkpoint plan-approved --instance "iteration-1:plan-v2" ``` + +Required verification matches both `subject` and `instanceId` and checks nonblank decision content, including historical records. A decision for an earlier iteration, patch, or finding cannot satisfy the current checkpoint. `oma doctor` reports malformed event envelopes and payloads without rewriting the authoritative log. + +Core kinds above have stable contracts. Additional event kinds may be used by extensions; they do not satisfy core decision or terminal contracts. diff --git a/.agents/skills/_shared/runtime/execution-protocols/opencode.md b/.agents/skills/_shared/runtime/execution-protocols/opencode.md index f7aedf42..d012ffb7 100644 --- a/.agents/skills/_shared/runtime/execution-protocols/opencode.md +++ b/.agents/skills/_shared/runtime/execution-protocols/opencode.md @@ -6,4 +6,4 @@ Follow [Execution Policy](../../core/execution-policy.md) and [Agent Result Cont When a long-lived Desktop MCP client is stuck, use the configured code-intelligence fallback in [Code Intelligence](../../core/code-intelligence.md); file-based state remains available. Repeated calls to the stuck client do not repair it. Report whether a full Desktop relaunch is needed; do not relaunch the app automatically. -Only when Serena is the configured provider: its CLI analysis commands are unavailable (`serena tools` lists/describes tools). Use native search/read; `serena project health-check` can diagnose the project. Do not switch providers or initialize/index a repository as an implicit recovery step. +Only when Serena is the configured provider: its CLI analysis commands are unavailable (`serena tools` lists/describes tools). Native search is only for paths outside this project or ignored paths; `serena project health-check` can diagnose the project. Do not switch providers or initialize/index a repository as an implicit recovery step. diff --git a/.agents/skills/_shared/runtime/result-contract.md b/.agents/skills/_shared/runtime/result-contract.md index d98e7ddd..038a142e 100644 --- a/.agents/skills/_shared/runtime/result-contract.md +++ b/.agents/skills/_shared/runtime/result-contract.md @@ -40,3 +40,14 @@ Optional `inputs` narrows hashing to declared project-relative files/directories `oma agent resume SESSION_ID --dry-run` reports reused, ready, running and blocked tasks. Without `--dry-run`, it retries ready tasks in dependency order, persists outcomes, and revalidates evidence before success. Changed dependencies invalidate dependent reuse even if their own inputs match. Live managed processes and native attempts without liveness evidence are not duplicated. Automatic replay requires `retry_policy: "safe"` and a prompt/agent in the plan or saved dispatch. The default is `manual`. `--max-attempts` defaults to 3 including the original attempt. A session lease prevents duplicate coordinators and permits recovery after the local owner dies. Mark interrupted native attempts partial/failed through their result contract before resuming. Keep the JSON plan fixed while recovery runs; record progress separately. This resumes tasks, not the model's interrupted conversation. + +## Plan lineage and evidence failures + +The plan's optional `lineage_id` defaults to its session ID. Keep it stable for the same workflow goal across resumed sessions. A task's optional `goal_id` defaults to its task ID; predeclared alternative task IDs for the same logical work must share it. Plan `max_attempts` defaults to 3 including the original attempt. Direct begin/spawn and resume count all runs for that lineage/goal, including runs without `resumedFrom`. `resume --max-attempts` can lower, but cannot raise, the frozen plan's bound. + +First dispatch pins the full plan under `.agents/state/agent-plans/`. Task IDs, prompts, dependencies, checks, scope, and retry limits are immutable. JSON formatting/key order and transport `session_id`/`created` metadata do not change the contract. Progress belongs in coordination notes. Unknown task IDs and dependency cycles fail before dispatch. Contract changes require an explicitly separated new session and lineage; record why the scope changed and preserve the prior run's outcome. Never use a new lineage to evade a retry limit or repair metadata for the same goal. Do not insert tasks that recursively regenerate/review the executing plan. + +- `PRODUCT_FAILURE`: executable checks failed, product inputs changed, or product work remains unresolved. Retry only within the shared goal budget. +- `WORKFLOW_EVIDENCE_FAILURE`: current product checks pass but a claim, artifact binding, or completion metadata is invalid. Automatic resume blocks product replay and returns the run/claim paths. One direct begin/spawn for that existing task may repair metadata within the same attempt budget; the run is marked `evidenceRepair`, and completion rejects product-input changes. Correct claim/report bindings and reverify the pinned checks, without changing the plan or dispatching planners/reviewers. A second evidence repair is rejected. If unresolved, stop with `partial`, distinguish product success from workflow failure, and provide the exact diagnostic and correction needed. + +Keep mutable task boards, phase logs, receipts, and repair notes under `.agents/state/`, `.agents/results/`, or `.serena/memories/`, which are excluded from product hashes. They may be bound as separate artifacts only after their final update; a later metadata edit invalidates that binding without making it a product failure. Do not narrow product `inputs` to hide relevant source changes. diff --git a/.agents/skills/_version.json b/.agents/skills/_version.json index 27fe44b6..66e4418b 100644 --- a/.agents/skills/_version.json +++ b/.agents/skills/_version.json @@ -1,6 +1,6 @@ { - "version": "14.14.3", + "version": "15.0.17", "schemaVersion": 2, "mode": "project", - "installedAt": "2026-09-23T05:30:22.183Z" + "installedAt": "2026-10-04T11:33:28.832Z" } diff --git a/.agents/skills/oma-academic-writing/SKILL.md b/.agents/skills/oma-academic-writing/SKILL.md index 8f823541..95653179 100644 --- a/.agents/skills/oma-academic-writing/SKILL.md +++ b/.agents/skills/oma-academic-writing/SKILL.md @@ -37,7 +37,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Expected inputs - `mode`: one of `draft` | `revise` | `review` -- `rubric_or_constraint`: assignment brief, rubric file, or word/structure limits (path or inline text) +- Optional `rubric_or_constraint`: assignment brief, rubric file, or word/structure limits (path or inline text); draft-only revision/review does not require a rubric - `existing_draft`: prior text to revise or audit (path or inline text); required for `revise` and `review` - `source_data`: available evidence, figures, citations the writer may use - `target_register`: defaults to formal academic English with American spelling (en-US) @@ -49,7 +49,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Dependencies - `../_shared/core/anti-ai-prose.md` and `resources/anti-ai-checklist.md`: common diagnostics and academic constraints; load together for prose audits -- `resources/sentence-structure-reference.md`: four sentence types, length targets, common errors +- `resources/sentence-structure-reference.md`: four sentence types, contextual rhythm guidance, common errors - `resources/academic-verb-tiers.md`: meaning- and evidence-based verb guidance - `resources/hedging-guide.md`: calibrated certainty expressions matched to evidence strength - `../_shared/core/context-loading.md`: task-relevant resource loading @@ -57,7 +57,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Control-flow features - Mode branching: `draft` vs `revise` vs `review` produce different output formats and pass sequences -- Rubric-quote gate: refuses to apply a rule until the literal constraint text is quoted from the source +- Rubric constraints: quote supplied assignment requirements before applying them; draft-only revision/review can proceed without inventing a rubric - Citation gap branch: when a claim lacks evidence, weaken or remove rather than fabricate; optionally hand off to `oma-scholar` - Language branch: non-English target hands off to `oma-translation` after the English pass - Iterative AUDIT: every fix loops back through the anti-AI checklist before emit @@ -66,7 +66,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Entry 1. Identify the mode (`draft`, `revise`, `review`) and the rubric source. -2. Quote the exact constraint text (word limits, structural requirements, mandatory sections, rubric rows) before applying any rule. +2. If assignment constraints were supplied, quote their exact text before applying them. Without a rubric, revise/review the draft using the requested register and evidence; do not invent assignment rules. 3. If revising or reviewing, read the existing draft in full first; if drafting, confirm available source data and citations. 4. Index `resources/` and identify any claims that need a more precise verb or hedge. 5. Apply the literal-first principle before any drafting: prefer direct statement over metaphor and flourish. When a literal phrase is available, use it. @@ -99,7 +99,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Exit - Success: every protocol PASSes, the Claim-Evidence Map has no unsupported entries, word count complies, and the mode-specific output format is fully populated. - Partial success: emit prose with explicit `needs evidence` / `pending citation` markers and report which protocol items remain at risk; flag handoff candidates. -- Failure: refuse to emit and report the blocking ambiguity (rubric quote missing, source data absent, contradictory constraints). +- Failure: report a material blocker such as required source data being unavailable or contradictory supplied constraints. The absence of an optional rubric alone does not block draft-only revision/review. ## Logical Operations @@ -130,7 +130,7 @@ Produce, revise, and audit publication-grade academic English prose so that ever - Output-format blocks per mode (Draft / Revision / Review) ### Canonical workflow path -1. **READ** rubric/draft and quote the exact literal constraint text; pin word limits, mandatory sections, and rubric rows. +1. **READ** the draft and any supplied rubric; quote actual assignment constraints and pin their requirements. If no rubric is supplied, use the requested revision/review scope without inventing constraints. 2. **PLAN** each paragraph as Topic-Support-Conclude; identify where evidence strength or a vague claim calls for a more precise verb. 3. **DRAFT** prose with sentence variety, clear verb choice, hedging, and Topic-Support-Conclude structure. 4. **AUDIT** with `../_shared/core/anti-ai-prose.md` and `resources/anti-ai-checklist.md`. Fix supported defects in draft/revise mode; in review mode, quote the passage, identify the defect, and recommend a local fix without a full rewrite or AI-authorship estimate. @@ -157,10 +157,10 @@ Produce, revise, and audit publication-grade academic English prose so that ever ### Guardrails 1. Every sentence must be verifiable; never fabricate data, statistics, or citations. -2. Quote-before-judgment: cite the literal constraint or rubric text before applying any rule. +2. Quote supplied rubric constraints before judging compliance. Without a rubric, explain prose/evidence findings against the requested scope rather than inventing assignment criteria. 3. Never combine distinct rules to invent a new constraint; apply rules exactly as written. 4. Choose verbs for their exact meaning and support. Keep common verbs when they are accurate and natural; replace a vague verb only when the new wording states a relevant distinction without inflating the claim. -5. Never place 3+ sentences of the same structural type consecutively; vary length (short 8–15, medium 16–25, long 26–40 words) and openers. +5. Review repeated structure and sentence length in context. Change rhythm where it improves clarity or emphasis; do not force sentence-type quotas or rewrite clear prose merely to vary it. 6. Match hedge strength to evidence strength per `hedging-guide.md`; never use absolute claim words (`definitely`, `clearly`, `obviously`) outside mathematical facts; never first-person `I think` / `I believe`. 7. Apply common prose diagnostics with the academic checklist's evidence and register exceptions. Vocabulary counts trigger contextual review, not automatic replacement of precise terms. 8. Em dashes ≤ 1 per paragraph; semicolons ≤ 2 per 1000 words; sentence-case headers; no didactic disclaimers (`It is important to note`) or summary phrases (`In summary`, `Overall`). @@ -175,4 +175,4 @@ Produce, revise, and audit publication-grade academic English prose so that ever - Academic verb tiers: `resources/academic-verb-tiers.md` - Hedging guide: `resources/hedging-guide.md` - Shared context loading: `../_shared/core/context-loading.md` -- Shared quality principles: `../_shared/core/quality-principles.md` +- Shared quality principles: `../_shared/core/quality-principles.md` \ No newline at end of file diff --git a/.agents/skills/oma-academic-writing/resources/sentence-structure-reference.md b/.agents/skills/oma-academic-writing/resources/sentence-structure-reference.md index af88f4b0..4cc03ac0 100644 --- a/.agents/skills/oma-academic-writing/resources/sentence-structure-reference.md +++ b/.agents/skills/oma-academic-writing/resources/sentence-structure-reference.md @@ -14,7 +14,7 @@ One independent clause in a subject-verb pattern. - This dataset contains 120 years of match records. - Performance declined sharply after the 2018 rule changes. -**Target:** 20–30% of sentences per paragraph. +**Guidance:** Use when a direct assertion is clearest; no paragraph quota. ### 2. Compound sentence @@ -28,7 +28,7 @@ Two independent clauses connected by a coordinating conjunction (FANBOYS: for, a - Match duration increased by 15%, and spectator attendance declined in parallel. - Nadal dominated the clay court, yet Djokovic maintained superiority on hard surfaces. -**Target:** 15–25% of sentences per paragraph. +**Guidance:** Use when the relationship between clauses matters; no paragraph quota. ### 3. Complex sentence @@ -42,7 +42,7 @@ One independent clause + one dependent clause (introduced by a subordinating con - Although the dataset spans 121 seasons, only matches after 1968 include detailed set-level data. - Because five-set matches impose greater physical demands, average rally length decreases in the fourth and fifth sets. -**Target:** 30–40% of sentences per paragraph (primary structure for academic prose). +**Guidance:** Use to state an actual causal, conditional, or temporal relationship; no paragraph quota. ### 4. Compound-complex sentence @@ -56,19 +56,16 @@ Two or more independent clauses + one or more dependent clauses. - While set durations vary considerably between Grand Slam events, the median match length has increased by 12 minutes since 2000, and this trend correlates with advances in racket technology. - Because the 1905–1968 era lacked professional circuits, participation remained limited to amateur players; however, match records from this period still provide valuable longitudinal data. -**Target:** 10–20% of sentences per paragraph (use sparingly for maximum impact). +**Guidance:** Use only when combining the clauses makes the argument clearer; no paragraph quota. ## Variation rules ### Within a paragraph -1. Never place 3+ sentences of the same type consecutively -2. Vary sentence length: - - Short: 8–15 words (for impact) - - Medium: 16–25 words (for flow) - - Long: 26–40 words (for depth) -3. Paragraphs of 4+ sentences should contain at least 3 of the 4 sentence types; short emphasis paragraphs (2–3 sentences) are exempt -4. Vary paragraph length (2–8 sentences) where it improves the argument; uniform blocks alone do not identify AI authorship +1. Review repeated structures where they obscure emphasis or progression. +2. Let sentence length follow the argument; split overloaded sentences and preserve useful short assertions. +3. Do not require a fixed mix of sentence types in each paragraph. +4. Vary paragraph length where it improves the argument; uniform blocks alone do not identify AI authorship. > For rhythm review and punctuation limits, see `anti-ai-checklist.md`, sections "Rhythm and sentence structure" and "Formatting". @@ -84,7 +81,7 @@ Two or more independent clauses + one or more dependent clauses. | Transitional phrase | "By contrast, the women's draw exhibited..." | | Inverted structure | "Particularly notable is the decline in..." | -**Rule:** No 3+ consecutive sentences beginning with the same opener type. +**Diagnostic:** Review repeated openers for clarity and emphasis; keep deliberate repetition when it serves the argument. ## Common errors to prevent diff --git a/.agents/skills/oma-architecture/SKILL.md b/.agents/skills/oma-architecture/SKILL.md index bdca7650..4f1c6d01 100644 --- a/.agents/skills/oma-architecture/SKILL.md +++ b/.agents/skills/oma-architecture/SKILL.md @@ -121,12 +121,12 @@ outputs: - Optional stakeholder-agent consultation only when cross-cutting enough to justify cost ### Canonical workflow path -Use the configured code-intelligence provider for structure, symbols, references, and integration points. If unavailable, use native search and scoped reads: +Use the configured code-intelligence provider for structure, symbols, references, and integration points. If unavailable, use native search only for paths outside this project or ignored paths: -```bash -ls .agents/results/architecture/ # prior decisions — read before deciding -rg --files -rg "ADR|architecture|boundary|service|module|dependency|owner|interface" . +```text +1. Read prior decisions in .agents/results/architecture/. +2. Discover the configured provider's file, symbol, reference, and pattern tools. +3. Inspect architecture-relevant modules, ownership, and integration points within the selected scope. ``` Then choose Diagnostic, Recommendation, Design-Twice, ATAM-style, CBAM-style, or ADR mode before writing the artifact. @@ -158,7 +158,7 @@ Then choose Diagnostic, Recommendation, Design-Twice, ATAM-style, CBAM-style, or 8. When a decision is material, compare at least two genuinely different options before recommending one. 9. Save architecture artifacts to `.agents/results/architecture/`. 10. Read prior artifacts in `.agents/results/architecture/` before deciding; when replacing an old decision, mark it superseded rather than contradicting it. -11. When a durable artifact is finalized, emit the `architecture.adr-complete` L1 decision event and verify the checkpoint (commands in `resources/execution-protocol.md` Step 7). +11. When a durable artifact is finalized in an active OMA workflow, record its actual recommendation, authority status, rationale, revision, and evidence with `architecture.adr-complete` (execution protocol Step 7). A completed proposal does not supply user approval or authorize implementation. ### Method Selection Summary - **Diagnostic Mode**: vague pain, unclear architecture symptom @@ -181,4 +181,4 @@ Then choose Diagnostic, Recommendation, Design-Twice, ATAM-style, CBAM-style, or - Context loading: `../_shared/core/context-loading.md` - Task decomposition: `../_shared/core/difficulty-guide.md` (unresolved scope or dependencies) - Clarification protocol: `../_shared/core/clarification-protocol.md` -- Quality principles: `../_shared/core/quality-principles.md` \ No newline at end of file +- Quality principles: `../_shared/core/quality-principles.md` diff --git a/.agents/skills/oma-architecture/resources/execution-protocol.md b/.agents/skills/oma-architecture/resources/execution-protocol.md index 81d3fdf2..3690dad4 100644 --- a/.agents/skills/oma-architecture/resources/execution-protocol.md +++ b/.agents/skills/oma-architecture/resources/execution-protocol.md @@ -98,13 +98,13 @@ Use the task's scope, existing project conventions, and acceptance criteria. Fol - `cbam-.md` - `diagnosis-.md` - Rerunning the same topic updates the existing file; record the revision in the ADR `Status` line rather than creating a copy -- ADR lifecycle: `Status` is `Proposed`, `Accepted`, or `Superseded by `; when a new ADR replaces an old one, update the old ADR's `Status` in the same run -- When running as a dispatched subagent, ALSO write the run report to `.agents/results/result-architecture.md` per the agent protocol; the report links to the durable artifact, it does not replace it -- Emit and verify the completion decision event: +- ADR lifecycle: `Status` is `Proposed`, `Accepted`, or `Superseded by `. Keep a user-owned unresolved choice `Proposed`; a completed artifact or event is not acceptance. Use `Accepted` only when existing decision authority supports it. Update a prior ADR as superseded only when the replacement is authorized. +- When dispatched, also write the injected claim and the task/run-scoped report per `../../_shared/runtime/result-contract.md` and `memory-protocol.md`. Use `result-{agentId}-{taskId}-{runId}-{sessionId}.md` under the configured memory base, preserving injected IDs. Link the architecture artifact from this report. +- In an active OMA workflow, record and verify the actual recommendation with its authority status and current artifact revision. This records completion of the analysis without granting implementation approval: ```bash -oma state emit "decision.made" '{"subject":"architecture.adr-complete","decision":"","rationale":""}' -oma state verify --workflow architecture --checkpoint adr-complete +oma state emit "decision.made" '{"subject":"architecture.adr-complete","instanceId":"","decision":": ","rationale":"